From 45f6f17eb0b0e1e6f28a9a77069397255c3b6e8f Mon Sep 17 00:00:00 2001 From: Kacper Date: Mon, 2 Feb 2026 15:47:18 +0100 Subject: [PATCH 001/156] fix(docker): Pre-install Playwright Chromium browsers for automated testing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #725 AI agents in automated testing mode require Playwright to verify implementations, but Docker containers had only system dependencies installed, not browser binaries. This caused verification failures with permissions errors. Changes: - Install Playwright Chromium in Dockerfile (~300MB increase) - Update docker-compose.override.yml.example with clearer Playwright documentation - Add "Playwright for Automated Testing" section to README - Document optional volume mount for persisting browsers across rebuilds Browsers are now pre-installed and work out of the box for Docker users. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- Dockerfile | 6 ++++++ README.md | 27 +++++++++++++++++++++++++++ docker-compose.override.yml.example | 11 ++++++++--- 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 03911b45..7d22858c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -118,6 +118,12 @@ RUN curl -fsSL https://opencode.ai/install | bash && \ echo "=== Checking OpenCode CLI installation ===" && \ ls -la /home/automaker/.local/bin/ && \ (which opencode && opencode --version) || echo "opencode installed (may need auth setup)" + +# Install Playwright Chromium browser for AI agent verification tests +# This adds ~300MB to the image but enables automated testing mode out of the box +RUN npx playwright install chromium && \ + echo "=== Playwright Chromium installed ===" && \ + ls -la /home/automaker/.cache/ms-playwright/ || echo "Playwright browsers installed" USER root # Add PATH to profile so it's available in all interactive shells (for login shells) diff --git a/README.md b/README.md index 75705673..645ba722 100644 --- a/README.md +++ b/README.md @@ -338,6 +338,33 @@ services: The Docker image supports both AMD64 and ARM64 architectures. The GitHub CLI and Claude CLI are automatically downloaded for the correct architecture during build. +##### Playwright for Automated Testing + +The Docker image includes **Playwright Chromium pre-installed** for AI agent verification tests. When agents implement features in automated testing mode, they use Playwright to verify the implementation works correctly. + +**No additional setup required** - Playwright verification works out of the box. + +**Optional: Persist browsers across container rebuilds** + +To avoid re-downloading browsers when rebuilding the Docker image, add this to your `docker-compose.override.yml`: + +```yaml +services: + server: + volumes: + - playwright-cache:/home/automaker/.cache/ms-playwright + +volumes: + playwright-cache: + name: automaker-playwright-cache +``` + +**Updating browsers manually:** + +```bash +docker exec automaker-server npx playwright install chromium +``` + ### Testing #### End-to-End Tests (Playwright) diff --git a/docker-compose.override.yml.example b/docker-compose.override.yml.example index 3815c197..d1f0c216 100644 --- a/docker-compose.override.yml.example +++ b/docker-compose.override.yml.example @@ -21,9 +21,13 @@ services: # - ~/.local/share/opencode:/home/automaker/.local/share/opencode # - ~/.config/opencode:/home/automaker/.config/opencode - # Playwright browser cache - persists installed browsers across container restarts - # Run 'npx playwright install --with-deps chromium' once, and it will persist + # ===== Playwright Browser Cache (Optional) ===== + # Playwright Chromium is PRE-INSTALLED in the Docker image for automated testing. + # Uncomment below to persist browser cache across container rebuilds (saves ~300MB download): # - playwright-cache:/home/automaker/.cache/ms-playwright + # + # To update Playwright browsers manually: + # docker exec automaker-server npx playwright install chromium environment: # Set root directory for all projects and file operations # Users can only create/open projects within this directory @@ -37,6 +41,7 @@ services: # - CURSOR_API_KEY=${CURSOR_API_KEY:-} volumes: - # Playwright cache volume (persists Chromium installs) + # Playwright cache volume - optional, persists browser updates across container rebuilds + # Uncomment if you mounted the playwright-cache volume above # playwright-cache: # name: automaker-playwright-cache From b37a287c9c63b6955e454e8497a16746a2231712 Mon Sep 17 00:00:00 2001 From: Kacper Date: Mon, 2 Feb 2026 15:55:11 +0100 Subject: [PATCH 002/156] fix(docker): Address PR #745 review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Clean up npx cache after Playwright installation to reduce image size - Clarify README: volume mounts persist cache across container lifecycles, not image rebuilds - Add first-use warning: empty volume overrides pre-installed browsers, users must re-install with docker exec command πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- Dockerfile | 4 +++- README.md | 13 +++++++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 7d22858c..f5c3511e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -121,9 +121,11 @@ RUN curl -fsSL https://opencode.ai/install | bash && \ # Install Playwright Chromium browser for AI agent verification tests # This adds ~300MB to the image but enables automated testing mode out of the box +# Clean up npx cache after installation to reduce image size RUN npx playwright install chromium && \ echo "=== Playwright Chromium installed ===" && \ - ls -la /home/automaker/.cache/ms-playwright/ || echo "Playwright browsers installed" + ls -la /home/automaker/.cache/ms-playwright/ || echo "Playwright browsers installed" && \ + rm -rf /home/automaker/.npm/_npx USER root # Add PATH to profile so it's available in all interactive shells (for login shells) diff --git a/README.md b/README.md index 645ba722..0c21245b 100644 --- a/README.md +++ b/README.md @@ -344,9 +344,18 @@ The Docker image includes **Playwright Chromium pre-installed** for AI agent ver **No additional setup required** - Playwright verification works out of the box. -**Optional: Persist browsers across container rebuilds** +**Optional: Persist browsers for manual updates** -To avoid re-downloading browsers when rebuilding the Docker image, add this to your `docker-compose.override.yml`: +By default, Playwright Chromium is pre-installed in the Docker image. If you need to manually update browsers or want to persist browser installations across container restarts (not image rebuilds), you can mount a volume. + +**Important:** When you first add this volume mount to an existing setup, the empty volume will override the pre-installed browsers. You must re-install them: + +```bash +# After adding the volume mount for the first time +docker exec automaker-server npx playwright install chromium +``` + +Add this to your `docker-compose.override.yml`: ```yaml services: From 3ccea7a67beee54f06060ecc4cfb8ebb0c307673 Mon Sep 17 00:00:00 2001 From: Kacper Date: Mon, 2 Feb 2026 16:07:53 +0100 Subject: [PATCH 003/156] fix(docker): Address remaining PR #745 review comments - Move Playwright install after node_modules copy to use pinned version - Use local playwright binary instead of npx to avoid registry fetch - Add --user automaker -w /app flags to docker exec commands - Change bold text to proper heading in README (MD036 lint fix) Co-Authored-By: Claude Opus 4.5 --- Dockerfile | 16 +++++++++------- README.md | 6 +++--- docker-compose.override.yml.example | 2 +- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/Dockerfile b/Dockerfile index f5c3511e..2e745e4c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -119,13 +119,6 @@ RUN curl -fsSL https://opencode.ai/install | bash && \ ls -la /home/automaker/.local/bin/ && \ (which opencode && opencode --version) || echo "opencode installed (may need auth setup)" -# Install Playwright Chromium browser for AI agent verification tests -# This adds ~300MB to the image but enables automated testing mode out of the box -# Clean up npx cache after installation to reduce image size -RUN npx playwright install chromium && \ - echo "=== Playwright Chromium installed ===" && \ - ls -la /home/automaker/.cache/ms-playwright/ || echo "Playwright browsers installed" && \ - rm -rf /home/automaker/.npm/_npx USER root # Add PATH to profile so it's available in all interactive shells (for login shells) @@ -155,6 +148,15 @@ COPY --from=server-builder /app/apps/server/package*.json ./apps/server/ # Copy node_modules (includes symlinks to libs) COPY --from=server-builder /app/node_modules ./node_modules +# Install Playwright Chromium browser for AI agent verification tests +# This adds ~300MB to the image but enables automated testing mode out of the box +# Using the locally installed playwright ensures we use the pinned version from package-lock.json +USER automaker +RUN ./node_modules/.bin/playwright install chromium && \ + echo "=== Playwright Chromium installed ===" && \ + ls -la /home/automaker/.cache/ms-playwright/ || echo "Playwright browsers installed" +USER root + # Create data and projects directories RUN mkdir -p /data /projects && chown automaker:automaker /data /projects diff --git a/README.md b/README.md index 0c21245b..95beefe1 100644 --- a/README.md +++ b/README.md @@ -344,7 +344,7 @@ The Docker image includes **Playwright Chromium pre-installed** for AI agent ver **No additional setup required** - Playwright verification works out of the box. -**Optional: Persist browsers for manual updates** +#### Optional: Persist browsers for manual updates By default, Playwright Chromium is pre-installed in the Docker image. If you need to manually update browsers or want to persist browser installations across container restarts (not image rebuilds), you can mount a volume. @@ -352,7 +352,7 @@ By default, Playwright Chromium is pre-installed in the Docker image. If you nee ```bash # After adding the volume mount for the first time -docker exec automaker-server npx playwright install chromium +docker exec --user automaker -w /app automaker-server npx playwright install chromium ``` Add this to your `docker-compose.override.yml`: @@ -371,7 +371,7 @@ volumes: **Updating browsers manually:** ```bash -docker exec automaker-server npx playwright install chromium +docker exec --user automaker -w /app automaker-server npx playwright install chromium ``` ### Testing diff --git a/docker-compose.override.yml.example b/docker-compose.override.yml.example index d1f0c216..e92ce119 100644 --- a/docker-compose.override.yml.example +++ b/docker-compose.override.yml.example @@ -27,7 +27,7 @@ services: # - playwright-cache:/home/automaker/.cache/ms-playwright # # To update Playwright browsers manually: - # docker exec automaker-server npx playwright install chromium + # docker exec --user automaker -w /app automaker-server npx playwright install chromium environment: # Set root directory for all projects and file operations # Users can only create/open projects within this directory From aad3ff2cdf74b7f872dc57eb8aa8117dae3b6952 Mon Sep 17 00:00:00 2001 From: Kacper Date: Mon, 2 Feb 2026 17:35:03 +0100 Subject: [PATCH 004/156] fix(auth): Improve OAuth credential detection and startup warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Enhanced getClaudeAuthIndicators() to return detailed check information including file paths checked and specific error details for debugging - Added debug logging to server startup credential detection for easier troubleshooting in Docker environments - Show paths that were checked in the warning message to help users debug mount issues - Added support for CLAUDE_CODE_OAUTH_TOKEN environment variable - Return authType in verify-claude-auth response to distinguish between OAuth and CLI authentication methods - Updated UI to show specific success messages for Claude Code subscription vs generic CLI auth - Added Docker troubleshooting tips to sandbox risk dialog - Added comprehensive unit tests for OAuth credential detection scenarios Closes #721 πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/server/src/index.ts | 101 ++- .../routes/setup/routes/verify-claude-auth.ts | 19 + .../dialogs/sandbox-risk-dialog.tsx | 23 + .../setup-view/steps/claude-setup-step.tsx | 25 +- apps/ui/src/lib/electron.ts | 1 + apps/ui/src/lib/http-api-client.ts | 1 + libs/platform/src/index.ts | 2 + libs/platform/src/system-paths.ts | 156 +++- .../tests/oauth-credential-detection.test.ts | 736 ++++++++++++++++++ 9 files changed, 1028 insertions(+), 36 deletions(-) create mode 100644 libs/platform/tests/oauth-credential-detection.test.ts diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 4bd496bc..c10702bb 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -121,21 +121,89 @@ const BOX_CONTENT_WIDTH = 67; // The Claude Agent SDK can use either ANTHROPIC_API_KEY or Claude Code CLI authentication (async () => { const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY; + const hasEnvOAuthToken = !!process.env.CLAUDE_CODE_OAUTH_TOKEN; + + logger.debug('[CREDENTIAL_CHECK] Starting credential detection...'); + logger.debug('[CREDENTIAL_CHECK] Environment variables:', { + hasAnthropicKey, + hasEnvOAuthToken, + }); if (hasAnthropicKey) { logger.info('βœ“ ANTHROPIC_API_KEY detected'); return; } + if (hasEnvOAuthToken) { + logger.info('βœ“ CLAUDE_CODE_OAUTH_TOKEN detected'); + return; + } + // Check for Claude Code CLI authentication + // Store indicators outside the try block so we can use them in the warning message + let cliAuthIndicators: Awaited> | null = null; + try { - const indicators = await getClaudeAuthIndicators(); + cliAuthIndicators = await getClaudeAuthIndicators(); + const indicators = cliAuthIndicators; + + // Log detailed credential detection results + logger.debug('[CREDENTIAL_CHECK] Claude CLI auth indicators:', { + hasCredentialsFile: indicators.hasCredentialsFile, + hasSettingsFile: indicators.hasSettingsFile, + hasStatsCacheWithActivity: indicators.hasStatsCacheWithActivity, + hasProjectsSessions: indicators.hasProjectsSessions, + credentials: indicators.credentials, + }); + + logger.debug('[CREDENTIAL_CHECK] File check details:', { + settingsFile: { + path: indicators.checks.settingsFile.path, + exists: indicators.checks.settingsFile.exists, + readable: indicators.checks.settingsFile.readable, + error: indicators.checks.settingsFile.error, + }, + statsCache: { + path: indicators.checks.statsCache.path, + exists: indicators.checks.statsCache.exists, + readable: indicators.checks.statsCache.readable, + hasDailyActivity: indicators.checks.statsCache.hasDailyActivity, + error: indicators.checks.statsCache.error, + }, + projectsDir: { + path: indicators.checks.projectsDir.path, + exists: indicators.checks.projectsDir.exists, + readable: indicators.checks.projectsDir.readable, + entryCount: indicators.checks.projectsDir.entryCount, + error: indicators.checks.projectsDir.error, + }, + credentialFiles: indicators.checks.credentialFiles.map((cf) => ({ + path: cf.path, + exists: cf.exists, + readable: cf.readable, + error: cf.error, + })), + }); + const hasCliAuth = indicators.hasStatsCacheWithActivity || (indicators.hasSettingsFile && indicators.hasProjectsSessions) || (indicators.hasCredentialsFile && (indicators.credentials?.hasOAuthToken || indicators.credentials?.hasApiKey)); + logger.debug('[CREDENTIAL_CHECK] Auth determination:', { + hasCliAuth, + reason: hasCliAuth + ? indicators.hasStatsCacheWithActivity + ? 'stats cache with activity' + : indicators.hasSettingsFile && indicators.hasProjectsSessions + ? 'settings file + project sessions' + : indicators.credentials?.hasOAuthToken + ? 'credentials file with OAuth token' + : 'credentials file with API key' + : 'no valid credentials found', + }); + if (hasCliAuth) { logger.info('βœ“ Claude Code CLI authentication detected'); return; @@ -145,7 +213,7 @@ const BOX_CONTENT_WIDTH = 67; logger.warn('Error checking for Claude Code CLI authentication:', error); } - // No authentication found - show warning + // No authentication found - show warning with paths that were checked const wHeader = '⚠️ WARNING: No Claude authentication configured'.padEnd(BOX_CONTENT_WIDTH); const w1 = 'The Claude Agent SDK requires authentication to function.'.padEnd(BOX_CONTENT_WIDTH); const w2 = 'Options:'.padEnd(BOX_CONTENT_WIDTH); @@ -158,6 +226,33 @@ const BOX_CONTENT_WIDTH = 67; BOX_CONTENT_WIDTH ); + // Build paths checked summary from the indicators (if available) + let pathsCheckedInfo = ''; + if (cliAuthIndicators) { + const pathsChecked: string[] = []; + + // Collect paths that were checked + if (cliAuthIndicators.checks.settingsFile.path) { + pathsChecked.push(`Settings: ${cliAuthIndicators.checks.settingsFile.path}`); + } + if (cliAuthIndicators.checks.statsCache.path) { + pathsChecked.push(`Stats cache: ${cliAuthIndicators.checks.statsCache.path}`); + } + if (cliAuthIndicators.checks.projectsDir.path) { + pathsChecked.push(`Projects dir: ${cliAuthIndicators.checks.projectsDir.path}`); + } + for (const credFile of cliAuthIndicators.checks.credentialFiles) { + pathsChecked.push(`Credentials: ${credFile.path}`); + } + + if (pathsChecked.length > 0) { + pathsCheckedInfo = ` +β•‘ β•‘ +β•‘ ${'Paths checked:'.padEnd(BOX_CONTENT_WIDTH)}β•‘ +${pathsChecked.map((p) => `β•‘ ${p.substring(0, BOX_CONTENT_WIDTH - 2).padEnd(BOX_CONTENT_WIDTH - 2)} β•‘`).join('\n')}`; + } + } + logger.warn(` ╔═════════════════════════════════════════════════════════════════════╗ β•‘ ${wHeader}β•‘ @@ -169,7 +264,7 @@ const BOX_CONTENT_WIDTH = 67; β•‘ ${w3}β•‘ β•‘ ${w4}β•‘ β•‘ ${w5}β•‘ -β•‘ ${w6}β•‘ +β•‘ ${w6}β•‘${pathsCheckedInfo} β•‘ β•‘ β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β• `); diff --git a/apps/server/src/routes/setup/routes/verify-claude-auth.ts b/apps/server/src/routes/setup/routes/verify-claude-auth.ts index df04d462..2a8d21b0 100644 --- a/apps/server/src/routes/setup/routes/verify-claude-auth.ts +++ b/apps/server/src/routes/setup/routes/verify-claude-auth.ts @@ -320,9 +320,28 @@ export function createVerifyClaudeAuthHandler() { authMethod, }); + // Determine specific auth type for success messages + let authType: 'oauth' | 'api_key' | 'cli' | undefined; + if (authenticated) { + if (authMethod === 'api_key') { + authType = 'api_key'; + } else if (authMethod === 'cli') { + // Check if CLI auth is via OAuth (Claude Code subscription) or generic CLI + // OAuth tokens are stored in the credentials file by the Claude CLI + const { getClaudeAuthIndicators } = await import('@automaker/platform'); + const indicators = await getClaudeAuthIndicators(); + if (indicators.credentials?.hasOAuthToken) { + authType = 'oauth'; + } else { + authType = 'cli'; + } + } + } + res.json({ success: true, authenticated, + authType, error: errorMessage || undefined, }); } catch (error) { diff --git a/apps/ui/src/components/dialogs/sandbox-risk-dialog.tsx b/apps/ui/src/components/dialogs/sandbox-risk-dialog.tsx index 3a5f6d35..7b597c8c 100644 --- a/apps/ui/src/components/dialogs/sandbox-risk-dialog.tsx +++ b/apps/ui/src/components/dialogs/sandbox-risk-dialog.tsx @@ -69,6 +69,29 @@ export function SandboxRiskDialog({ open, onConfirm, onDeny }: SandboxRiskDialog For safer operation, consider running Automaker in Docker. See the README for instructions.

+ +
+

+ Already running in Docker? Try these troubleshooting steps: +

+
    +
  • + Ensure IS_CONTAINERIZED=true is + set in your docker-compose environment +
  • +
  • + Verify the server container has the environment variable:{' '} + + docker exec automaker-server printenv IS_CONTAINERIZED + +
  • +
  • Rebuild and restart containers if you recently changed the configuration
  • +
  • + Check the server logs for startup messages:{' '} + docker-compose logs server +
  • +
+
diff --git a/apps/ui/src/components/views/setup-view/steps/claude-setup-step.tsx b/apps/ui/src/components/views/setup-view/steps/claude-setup-step.tsx index b864bfdb..0b4799d6 100644 --- a/apps/ui/src/components/views/setup-view/steps/claude-setup-step.tsx +++ b/apps/ui/src/components/views/setup-view/steps/claude-setup-step.tsx @@ -59,6 +59,7 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps // CLI Verification state const [cliVerificationStatus, setCliVerificationStatus] = useState('idle'); const [cliVerificationError, setCliVerificationError] = useState(null); + const [cliAuthType, setCliAuthType] = useState<'oauth' | 'cli' | null>(null); // API Key Verification state const [apiKeyVerificationStatus, setApiKeyVerificationStatus] = @@ -119,6 +120,7 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps const verifyCliAuth = useCallback(async () => { setCliVerificationStatus('verifying'); setCliVerificationError(null); + setCliAuthType(null); try { const api = getElectronAPI(); @@ -138,12 +140,21 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps if (result.authenticated && !hasLimitReachedError) { setCliVerificationStatus('verified'); + // Store the auth type for displaying specific success message + const authType = result.authType === 'oauth' ? 'oauth' : 'cli'; + setCliAuthType(authType); setClaudeAuthStatus({ authenticated: true, - method: 'cli_authenticated', + method: authType === 'oauth' ? 'oauth_token' : 'cli_authenticated', hasCredentialsFile: claudeAuthStatus?.hasCredentialsFile || false, + oauthTokenValid: authType === 'oauth', }); - toast.success('Claude CLI authentication verified!'); + // Show specific success message based on auth type + if (authType === 'oauth') { + toast.success('Claude Code subscription detected and verified!'); + } else { + toast.success('Claude CLI authentication verified!'); + } } else { setCliVerificationStatus('error'); setCliVerificationError( @@ -436,9 +447,15 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps
-

CLI Authentication verified!

+

+ {cliAuthType === 'oauth' + ? 'Claude Code subscription verified!' + : 'CLI Authentication verified!'} +

- Your Claude CLI is working correctly. + {cliAuthType === 'oauth' + ? 'Your Claude Code subscription is active and ready to use.' + : 'Your Claude CLI is working correctly.'}

diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index 89aa07ba..22079822 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -1442,6 +1442,7 @@ interface SetupAPI { verifyClaudeAuth: (authMethod?: 'cli' | 'api_key') => Promise<{ success: boolean; authenticated: boolean; + authType?: 'oauth' | 'api_key' | 'cli'; error?: string; }>; getGhStatus?: () => Promise<{ diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 1f79ff07..acd75d22 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -1350,6 +1350,7 @@ export class HttpApiClient implements ElectronAPI { ): Promise<{ success: boolean; authenticated: boolean; + authType?: 'oauth' | 'api_key' | 'cli'; error?: string; }> => this.post('/api/setup/verify-claude-auth', { authMethod, apiKey }), diff --git a/libs/platform/src/index.ts b/libs/platform/src/index.ts index 5952ba2d..5c0b8078 100644 --- a/libs/platform/src/index.ts +++ b/libs/platform/src/index.ts @@ -134,6 +134,8 @@ export { findClaudeCliPath, getClaudeAuthIndicators, type ClaudeAuthIndicators, + type FileCheckResult, + type DirectoryCheckResult, findCodexCliPath, getCodexAuthIndicators, type CodexAuthIndicators, diff --git a/libs/platform/src/system-paths.ts b/libs/platform/src/system-paths.ts index 0d900dfa..fb5e6bd3 100644 --- a/libs/platform/src/system-paths.ts +++ b/libs/platform/src/system-paths.ts @@ -976,6 +976,27 @@ export async function findGitBashPath(): Promise { return findFirstExistingPath(getGitBashPaths()); } +/** + * Details about a file check performed during auth detection + */ +export interface FileCheckResult { + path: string; + exists: boolean; + readable: boolean; + error?: string; +} + +/** + * Details about a directory check performed during auth detection + */ +export interface DirectoryCheckResult { + path: string; + exists: boolean; + readable: boolean; + entryCount: number; + error?: string; +} + /** * Get Claude authentication status by checking various indicators */ @@ -988,67 +1009,144 @@ export interface ClaudeAuthIndicators { hasOAuthToken: boolean; hasApiKey: boolean; } | null; + /** Detailed information about what was checked */ + checks: { + settingsFile: FileCheckResult; + statsCache: FileCheckResult & { hasDailyActivity?: boolean }; + projectsDir: DirectoryCheckResult; + credentialFiles: FileCheckResult[]; + }; } export async function getClaudeAuthIndicators(): Promise { + const settingsPath = getClaudeSettingsPath(); + const statsCachePath = getClaudeStatsCachePath(); + const projectsDir = getClaudeProjectsDir(); + const credentialPaths = getClaudeCredentialPaths(); + + // Initialize checks with paths + const settingsFileCheck: FileCheckResult = { + path: settingsPath, + exists: false, + readable: false, + }; + + const statsCacheCheck: FileCheckResult & { hasDailyActivity?: boolean } = { + path: statsCachePath, + exists: false, + readable: false, + }; + + const projectsDirCheck: DirectoryCheckResult = { + path: projectsDir, + exists: false, + readable: false, + entryCount: 0, + }; + + const credentialFileChecks: FileCheckResult[] = credentialPaths.map((p) => ({ + path: p, + exists: false, + readable: false, + })); + const result: ClaudeAuthIndicators = { hasCredentialsFile: false, hasSettingsFile: false, hasStatsCacheWithActivity: false, hasProjectsSessions: false, credentials: null, + checks: { + settingsFile: settingsFileCheck, + statsCache: statsCacheCheck, + projectsDir: projectsDirCheck, + credentialFiles: credentialFileChecks, + }, }; // Check settings file try { - if (await systemPathAccess(getClaudeSettingsPath())) { + if (await systemPathAccess(settingsPath)) { + settingsFileCheck.exists = true; + settingsFileCheck.readable = true; result.hasSettingsFile = true; } - } catch { - // Ignore errors + } catch (err) { + settingsFileCheck.error = err instanceof Error ? err.message : String(err); } // Check stats cache for recent activity try { - const statsContent = await systemPathReadFile(getClaudeStatsCachePath()); - const stats = JSON.parse(statsContent); - if (stats.dailyActivity && stats.dailyActivity.length > 0) { - result.hasStatsCacheWithActivity = true; + const statsContent = await systemPathReadFile(statsCachePath); + statsCacheCheck.exists = true; + statsCacheCheck.readable = true; + try { + const stats = JSON.parse(statsContent); + if (stats.dailyActivity && stats.dailyActivity.length > 0) { + statsCacheCheck.hasDailyActivity = true; + result.hasStatsCacheWithActivity = true; + } else { + statsCacheCheck.hasDailyActivity = false; + } + } catch (parseErr) { + statsCacheCheck.error = `JSON parse error: ${parseErr instanceof Error ? parseErr.message : String(parseErr)}`; + } + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + statsCacheCheck.exists = false; + } else { + statsCacheCheck.error = err instanceof Error ? err.message : String(err); } - } catch { - // Ignore errors } // Check for sessions in projects directory try { - const sessions = await systemPathReaddir(getClaudeProjectsDir()); + const sessions = await systemPathReaddir(projectsDir); + projectsDirCheck.exists = true; + projectsDirCheck.readable = true; + projectsDirCheck.entryCount = sessions.length; if (sessions.length > 0) { result.hasProjectsSessions = true; } - } catch { - // Ignore errors + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + projectsDirCheck.exists = false; + } else { + projectsDirCheck.error = err instanceof Error ? err.message : String(err); + } } // Check credentials files - const credentialPaths = getClaudeCredentialPaths(); - for (const credPath of credentialPaths) { + for (let i = 0; i < credentialPaths.length; i++) { + const credPath = credentialPaths[i]; + const credCheck = credentialFileChecks[i]; try { const content = await systemPathReadFile(credPath); - const credentials = JSON.parse(content); - result.hasCredentialsFile = true; - // Support multiple credential formats: - // 1. Claude Code CLI format: { claudeAiOauth: { accessToken, refreshToken } } - // 2. Legacy format: { oauth_token } or { access_token } - // 3. API key format: { api_key } - const hasClaudeOauth = !!credentials.claudeAiOauth?.accessToken; - const hasLegacyOauth = !!(credentials.oauth_token || credentials.access_token); - result.credentials = { - hasOAuthToken: hasClaudeOauth || hasLegacyOauth, - hasApiKey: !!credentials.api_key, - }; - break; - } catch { - // Continue to next path + credCheck.exists = true; + credCheck.readable = true; + try { + const credentials = JSON.parse(content); + result.hasCredentialsFile = true; + // Support multiple credential formats: + // 1. Claude Code CLI format: { claudeAiOauth: { accessToken, refreshToken } } + // 2. Legacy format: { oauth_token } or { access_token } + // 3. API key format: { api_key } + const hasClaudeOauth = !!credentials.claudeAiOauth?.accessToken; + const hasLegacyOauth = !!(credentials.oauth_token || credentials.access_token); + result.credentials = { + hasOAuthToken: hasClaudeOauth || hasLegacyOauth, + hasApiKey: !!credentials.api_key, + }; + break; + } catch (parseErr) { + credCheck.error = `JSON parse error: ${parseErr instanceof Error ? parseErr.message : String(parseErr)}`; + } + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + credCheck.exists = false; + } else { + credCheck.error = err instanceof Error ? err.message : String(err); + } } } diff --git a/libs/platform/tests/oauth-credential-detection.test.ts b/libs/platform/tests/oauth-credential-detection.test.ts new file mode 100644 index 00000000..cf5a4705 --- /dev/null +++ b/libs/platform/tests/oauth-credential-detection.test.ts @@ -0,0 +1,736 @@ +/** + * Unit tests for OAuth credential detection scenarios + * + * Tests the various Claude credential detection formats including: + * - Claude Code CLI OAuth format (claudeAiOauth) + * - Legacy OAuth token format (oauth_token, access_token) + * - API key format (api_key) + * - Invalid/malformed credential files + * + * These tests use real temp directories to avoid complex fs mocking issues. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'fs/promises'; +import path from 'path'; +import os from 'os'; + +describe('OAuth Credential Detection', () => { + let tempDir: string; + let originalHomedir: () => string; + let mockClaudeDir: string; + let mockCodexDir: string; + let mockOpenCodeDir: string; + + beforeEach(async () => { + // Reset modules to get fresh state + vi.resetModules(); + + // Create a temporary directory + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'oauth-detection-test-')); + + // Create mock home directory structure + mockClaudeDir = path.join(tempDir, '.claude'); + mockCodexDir = path.join(tempDir, '.codex'); + mockOpenCodeDir = path.join(tempDir, '.local', 'share', 'opencode'); + + await fs.mkdir(mockClaudeDir, { recursive: true }); + await fs.mkdir(mockCodexDir, { recursive: true }); + await fs.mkdir(mockOpenCodeDir, { recursive: true }); + + // Mock os.homedir to return our temp directory + originalHomedir = os.homedir; + vi.spyOn(os, 'homedir').mockReturnValue(tempDir); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + // Clean up temp directory + try { + await fs.rm(tempDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + describe('getClaudeAuthIndicators', () => { + it('should detect Claude Code CLI OAuth format (claudeAiOauth)', async () => { + const credentialsContent = JSON.stringify({ + claudeAiOauth: { + accessToken: 'oauth-access-token-12345', + refreshToken: 'oauth-refresh-token-67890', + expiresAt: Date.now() + 3600000, + }, + }); + + await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), credentialsContent); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasCredentialsFile).toBe(true); + expect(indicators.credentials).not.toBeNull(); + expect(indicators.credentials?.hasOAuthToken).toBe(true); + expect(indicators.credentials?.hasApiKey).toBe(false); + }); + + it('should detect legacy OAuth token format (oauth_token)', async () => { + const credentialsContent = JSON.stringify({ + oauth_token: 'legacy-oauth-token-abcdef', + }); + + await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), credentialsContent); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasCredentialsFile).toBe(true); + expect(indicators.credentials?.hasOAuthToken).toBe(true); + expect(indicators.credentials?.hasApiKey).toBe(false); + }); + + it('should detect legacy access_token format', async () => { + const credentialsContent = JSON.stringify({ + access_token: 'legacy-access-token-xyz', + }); + + await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), credentialsContent); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasCredentialsFile).toBe(true); + expect(indicators.credentials?.hasOAuthToken).toBe(true); + expect(indicators.credentials?.hasApiKey).toBe(false); + }); + + it('should detect API key format', async () => { + const credentialsContent = JSON.stringify({ + api_key: 'sk-ant-api03-xxxxxxxxxxxx', + }); + + await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), credentialsContent); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasCredentialsFile).toBe(true); + expect(indicators.credentials?.hasOAuthToken).toBe(false); + expect(indicators.credentials?.hasApiKey).toBe(true); + }); + + it('should detect both OAuth and API key when present', async () => { + const credentialsContent = JSON.stringify({ + claudeAiOauth: { + accessToken: 'oauth-token', + refreshToken: 'refresh-token', + }, + api_key: 'sk-ant-api03-xxxxxxxxxxxx', + }); + + await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), credentialsContent); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasCredentialsFile).toBe(true); + expect(indicators.credentials?.hasOAuthToken).toBe(true); + expect(indicators.credentials?.hasApiKey).toBe(true); + }); + + it('should handle missing credentials file gracefully', async () => { + // No credentials file created + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasCredentialsFile).toBe(false); + expect(indicators.credentials).toBeNull(); + expect(indicators.checks.credentialFiles).toBeDefined(); + expect(indicators.checks.credentialFiles.length).toBeGreaterThan(0); + expect(indicators.checks.credentialFiles[0].exists).toBe(false); + }); + + it('should handle malformed JSON in credentials file', async () => { + const malformedContent = '{ invalid json }'; + + await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), malformedContent); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + // File exists but parsing fails + expect(indicators.hasCredentialsFile).toBe(false); + expect(indicators.credentials).toBeNull(); + expect(indicators.checks.credentialFiles[0].exists).toBe(true); + expect(indicators.checks.credentialFiles[0].error).toContain('JSON parse error'); + }); + + it('should handle empty credentials file', async () => { + const emptyContent = JSON.stringify({}); + + await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), emptyContent); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasCredentialsFile).toBe(true); + expect(indicators.credentials).not.toBeNull(); + expect(indicators.credentials?.hasOAuthToken).toBe(false); + expect(indicators.credentials?.hasApiKey).toBe(false); + }); + + it('should handle credentials file with null values', async () => { + const nullContent = JSON.stringify({ + claudeAiOauth: null, + api_key: null, + oauth_token: null, + }); + + await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), nullContent); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasCredentialsFile).toBe(true); + expect(indicators.credentials?.hasOAuthToken).toBe(false); + expect(indicators.credentials?.hasApiKey).toBe(false); + }); + + it('should handle credentials with empty string values', async () => { + const emptyStrings = JSON.stringify({ + claudeAiOauth: { + accessToken: '', + refreshToken: '', + }, + api_key: '', + }); + + await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), emptyStrings); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasCredentialsFile).toBe(true); + // Empty strings should not be treated as valid credentials + expect(indicators.credentials?.hasOAuthToken).toBe(false); + expect(indicators.credentials?.hasApiKey).toBe(false); + }); + + it('should detect settings file presence', async () => { + await fs.writeFile( + path.join(mockClaudeDir, 'settings.json'), + JSON.stringify({ theme: 'dark' }) + ); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasSettingsFile).toBe(true); + expect(indicators.checks.settingsFile.exists).toBe(true); + expect(indicators.checks.settingsFile.readable).toBe(true); + }); + + it('should detect stats cache with activity', async () => { + const statsContent = JSON.stringify({ + dailyActivity: [ + { date: '2025-01-15', messagesCount: 10 }, + { date: '2025-01-16', messagesCount: 5 }, + ], + }); + + await fs.writeFile(path.join(mockClaudeDir, 'stats-cache.json'), statsContent); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasStatsCacheWithActivity).toBe(true); + expect(indicators.checks.statsCache.exists).toBe(true); + expect(indicators.checks.statsCache.hasDailyActivity).toBe(true); + }); + + it('should detect stats cache without activity', async () => { + const statsContent = JSON.stringify({ + dailyActivity: [], + }); + + await fs.writeFile(path.join(mockClaudeDir, 'stats-cache.json'), statsContent); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasStatsCacheWithActivity).toBe(false); + expect(indicators.checks.statsCache.exists).toBe(true); + expect(indicators.checks.statsCache.hasDailyActivity).toBe(false); + }); + + it('should detect project sessions', async () => { + const projectsDir = path.join(mockClaudeDir, 'projects'); + await fs.mkdir(projectsDir, { recursive: true }); + await fs.mkdir(path.join(projectsDir, 'session-1')); + await fs.mkdir(path.join(projectsDir, 'session-2')); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasProjectsSessions).toBe(true); + expect(indicators.checks.projectsDir.exists).toBe(true); + expect(indicators.checks.projectsDir.entryCount).toBe(2); + }); + + it('should return comprehensive check details', async () => { + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + // Verify all check detail objects are present + expect(indicators.checks).toBeDefined(); + expect(indicators.checks.settingsFile).toBeDefined(); + expect(indicators.checks.settingsFile.path).toContain('settings.json'); + expect(indicators.checks.statsCache).toBeDefined(); + expect(indicators.checks.statsCache.path).toContain('stats-cache.json'); + expect(indicators.checks.projectsDir).toBeDefined(); + expect(indicators.checks.projectsDir.path).toContain('projects'); + expect(indicators.checks.credentialFiles).toBeDefined(); + expect(Array.isArray(indicators.checks.credentialFiles)).toBe(true); + }); + + it('should try both .credentials.json and credentials.json paths', async () => { + // Write to credentials.json (without leading dot) + const credentialsContent = JSON.stringify({ + api_key: 'sk-test-key', + }); + + await fs.writeFile(path.join(mockClaudeDir, 'credentials.json'), credentialsContent); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + // Should find credentials in the second path + expect(indicators.hasCredentialsFile).toBe(true); + expect(indicators.credentials?.hasApiKey).toBe(true); + }); + + it('should prefer first credentials file if both exist', async () => { + // Write OAuth to .credentials.json (first path checked) + await fs.writeFile( + path.join(mockClaudeDir, '.credentials.json'), + JSON.stringify({ + claudeAiOauth: { + accessToken: 'oauth-token', + refreshToken: 'refresh-token', + }, + }) + ); + + // Write API key to credentials.json (second path) + await fs.writeFile( + path.join(mockClaudeDir, 'credentials.json'), + JSON.stringify({ + api_key: 'sk-test-key', + }) + ); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + // Should use first file (.credentials.json) which has OAuth + expect(indicators.hasCredentialsFile).toBe(true); + expect(indicators.credentials?.hasOAuthToken).toBe(true); + expect(indicators.credentials?.hasApiKey).toBe(false); + }); + }); + + describe('getCodexAuthIndicators', () => { + it('should detect OAuth token in Codex auth file', async () => { + const authContent = JSON.stringify({ + access_token: 'codex-oauth-token-12345', + }); + + await fs.writeFile(path.join(mockCodexDir, 'auth.json'), authContent); + + const { getCodexAuthIndicators } = await import('../src/system-paths'); + const indicators = await getCodexAuthIndicators(); + + expect(indicators.hasAuthFile).toBe(true); + expect(indicators.hasOAuthToken).toBe(true); + expect(indicators.hasApiKey).toBe(false); + }); + + it('should detect API key in Codex auth file', async () => { + const authContent = JSON.stringify({ + OPENAI_API_KEY: 'sk-xxxxxxxxxxxxxxxx', + }); + + await fs.writeFile(path.join(mockCodexDir, 'auth.json'), authContent); + + const { getCodexAuthIndicators } = await import('../src/system-paths'); + const indicators = await getCodexAuthIndicators(); + + expect(indicators.hasAuthFile).toBe(true); + expect(indicators.hasOAuthToken).toBe(false); + expect(indicators.hasApiKey).toBe(true); + }); + + it('should detect nested tokens in Codex auth file', async () => { + const authContent = JSON.stringify({ + tokens: { + oauth_token: 'nested-oauth-token', + }, + }); + + await fs.writeFile(path.join(mockCodexDir, 'auth.json'), authContent); + + const { getCodexAuthIndicators } = await import('../src/system-paths'); + const indicators = await getCodexAuthIndicators(); + + expect(indicators.hasAuthFile).toBe(true); + expect(indicators.hasOAuthToken).toBe(true); + }); + + it('should handle missing Codex auth file', async () => { + // No auth file created + const { getCodexAuthIndicators } = await import('../src/system-paths'); + const indicators = await getCodexAuthIndicators(); + + expect(indicators.hasAuthFile).toBe(false); + expect(indicators.hasOAuthToken).toBe(false); + expect(indicators.hasApiKey).toBe(false); + }); + + it('should detect api_key field in Codex auth', async () => { + const authContent = JSON.stringify({ + api_key: 'sk-api-key-value', + }); + + await fs.writeFile(path.join(mockCodexDir, 'auth.json'), authContent); + + const { getCodexAuthIndicators } = await import('../src/system-paths'); + const indicators = await getCodexAuthIndicators(); + + expect(indicators.hasAuthFile).toBe(true); + expect(indicators.hasApiKey).toBe(true); + }); + }); + + describe('getOpenCodeAuthIndicators', () => { + it('should detect provider-specific OAuth credentials', async () => { + const authContent = JSON.stringify({ + anthropic: { + type: 'oauth', + access: 'oauth-access-token', + refresh: 'oauth-refresh-token', + }, + }); + + await fs.writeFile(path.join(mockOpenCodeDir, 'auth.json'), authContent); + + const { getOpenCodeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getOpenCodeAuthIndicators(); + + expect(indicators.hasAuthFile).toBe(true); + expect(indicators.hasOAuthToken).toBe(true); + expect(indicators.hasApiKey).toBe(false); + }); + + it('should detect GitHub Copilot refresh token as OAuth', async () => { + const authContent = JSON.stringify({ + 'github-copilot': { + type: 'oauth', + access: '', // Empty access token + refresh: 'gh-refresh-token', // But has refresh token + }, + }); + + await fs.writeFile(path.join(mockOpenCodeDir, 'auth.json'), authContent); + + const { getOpenCodeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getOpenCodeAuthIndicators(); + + expect(indicators.hasAuthFile).toBe(true); + expect(indicators.hasOAuthToken).toBe(true); + }); + + it('should detect provider-specific API key credentials', async () => { + const authContent = JSON.stringify({ + openai: { + type: 'api_key', + key: 'sk-xxxxxxxxxxxx', + }, + }); + + await fs.writeFile(path.join(mockOpenCodeDir, 'auth.json'), authContent); + + const { getOpenCodeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getOpenCodeAuthIndicators(); + + expect(indicators.hasAuthFile).toBe(true); + expect(indicators.hasOAuthToken).toBe(false); + expect(indicators.hasApiKey).toBe(true); + }); + + it('should detect multiple providers', async () => { + const authContent = JSON.stringify({ + anthropic: { + type: 'oauth', + access: 'anthropic-token', + refresh: 'refresh-token', + }, + openai: { + type: 'api_key', + key: 'sk-xxxxxxxxxxxx', + }, + }); + + await fs.writeFile(path.join(mockOpenCodeDir, 'auth.json'), authContent); + + const { getOpenCodeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getOpenCodeAuthIndicators(); + + expect(indicators.hasAuthFile).toBe(true); + expect(indicators.hasOAuthToken).toBe(true); + expect(indicators.hasApiKey).toBe(true); + }); + + it('should handle missing OpenCode auth file', async () => { + // No auth file created + const { getOpenCodeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getOpenCodeAuthIndicators(); + + expect(indicators.hasAuthFile).toBe(false); + expect(indicators.hasOAuthToken).toBe(false); + expect(indicators.hasApiKey).toBe(false); + }); + + it('should handle legacy top-level OAuth keys', async () => { + const authContent = JSON.stringify({ + access_token: 'legacy-access-token', + }); + + await fs.writeFile(path.join(mockOpenCodeDir, 'auth.json'), authContent); + + const { getOpenCodeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getOpenCodeAuthIndicators(); + + expect(indicators.hasAuthFile).toBe(true); + expect(indicators.hasOAuthToken).toBe(true); + }); + + it('should detect copilot provider OAuth', async () => { + const authContent = JSON.stringify({ + copilot: { + type: 'oauth', + access: 'copilot-access-token', + refresh: 'copilot-refresh-token', + }, + }); + + await fs.writeFile(path.join(mockOpenCodeDir, 'auth.json'), authContent); + + const { getOpenCodeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getOpenCodeAuthIndicators(); + + expect(indicators.hasAuthFile).toBe(true); + expect(indicators.hasOAuthToken).toBe(true); + }); + }); + + describe('Credential path helpers', () => { + it('should return correct Claude credential paths', async () => { + const { getClaudeCredentialPaths, getClaudeConfigDir } = await import('../src/system-paths'); + + const configDir = getClaudeConfigDir(); + expect(configDir).toContain('.claude'); + + const credPaths = getClaudeCredentialPaths(); + expect(credPaths.length).toBeGreaterThan(0); + expect(credPaths.some((p) => p.includes('.credentials.json'))).toBe(true); + expect(credPaths.some((p) => p.includes('credentials.json'))).toBe(true); + }); + + it('should return correct Codex auth path', async () => { + const { getCodexAuthPath, getCodexConfigDir } = await import('../src/system-paths'); + + const configDir = getCodexConfigDir(); + expect(configDir).toContain('.codex'); + + const authPath = getCodexAuthPath(); + expect(authPath).toContain('.codex'); + expect(authPath).toContain('auth.json'); + }); + + it('should return correct OpenCode auth path', async () => { + const { getOpenCodeAuthPath, getOpenCodeConfigDir } = await import('../src/system-paths'); + + const configDir = getOpenCodeConfigDir(); + expect(configDir).toContain('opencode'); + + const authPath = getOpenCodeAuthPath(); + expect(authPath).toContain('opencode'); + expect(authPath).toContain('auth.json'); + }); + }); + + describe('Edge cases for credential detection', () => { + it('should handle credentials file with unexpected structure', async () => { + const unexpectedContent = JSON.stringify({ + someUnexpectedKey: 'value', + nested: { + deeply: { + unexpected: true, + }, + }, + }); + + await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), unexpectedContent); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasCredentialsFile).toBe(true); + expect(indicators.credentials?.hasOAuthToken).toBe(false); + expect(indicators.credentials?.hasApiKey).toBe(false); + }); + + it('should handle array instead of object in credentials', async () => { + const arrayContent = JSON.stringify(['token1', 'token2']); + + await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), arrayContent); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + // Array is valid JSON but wrong structure - should handle gracefully + expect(indicators.hasCredentialsFile).toBe(true); + expect(indicators.credentials?.hasOAuthToken).toBe(false); + expect(indicators.credentials?.hasApiKey).toBe(false); + }); + + it('should handle numeric values in credential fields', async () => { + const numericContent = JSON.stringify({ + api_key: 12345, + oauth_token: 67890, + }); + + await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), numericContent); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + // Note: Current implementation uses JavaScript truthiness which accepts numbers + // This documents the actual behavior - ideally would validate string type + expect(indicators.hasCredentialsFile).toBe(true); + // The implementation checks truthiness, not strict string type + expect(indicators.credentials?.hasOAuthToken).toBe(true); + expect(indicators.credentials?.hasApiKey).toBe(true); + }); + + it('should handle boolean values in credential fields', async () => { + const booleanContent = JSON.stringify({ + api_key: true, + oauth_token: false, + }); + + await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), booleanContent); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + // Note: Current implementation uses JavaScript truthiness + // api_key: true is truthy, oauth_token: false is falsy + expect(indicators.hasCredentialsFile).toBe(true); + expect(indicators.credentials?.hasOAuthToken).toBe(false); // false is falsy + expect(indicators.credentials?.hasApiKey).toBe(true); // true is truthy + }); + + it('should handle malformed stats-cache.json gracefully', async () => { + await fs.writeFile(path.join(mockClaudeDir, 'stats-cache.json'), '{ invalid json }'); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasStatsCacheWithActivity).toBe(false); + expect(indicators.checks.statsCache.exists).toBe(true); + expect(indicators.checks.statsCache.error).toBeDefined(); + }); + + it('should handle empty projects directory', async () => { + const projectsDir = path.join(mockClaudeDir, 'projects'); + await fs.mkdir(projectsDir, { recursive: true }); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasProjectsSessions).toBe(false); + expect(indicators.checks.projectsDir.exists).toBe(true); + expect(indicators.checks.projectsDir.entryCount).toBe(0); + }); + }); + + describe('Combined authentication scenarios', () => { + it('should detect CLI authenticated state with settings + sessions', async () => { + // Create settings file + await fs.writeFile( + path.join(mockClaudeDir, 'settings.json'), + JSON.stringify({ theme: 'dark' }) + ); + + // Create projects directory with sessions + const projectsDir = path.join(mockClaudeDir, 'projects'); + await fs.mkdir(projectsDir, { recursive: true }); + await fs.mkdir(path.join(projectsDir, 'session-1')); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasSettingsFile).toBe(true); + expect(indicators.hasProjectsSessions).toBe(true); + }); + + it('should detect recent activity indicating working auth', async () => { + // Create stats cache with recent activity + await fs.writeFile( + path.join(mockClaudeDir, 'stats-cache.json'), + JSON.stringify({ + dailyActivity: [{ date: new Date().toISOString().split('T')[0], messagesCount: 10 }], + }) + ); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasStatsCacheWithActivity).toBe(true); + }); + + it('should handle complete auth setup', async () => { + // Create all auth indicators + await fs.writeFile( + path.join(mockClaudeDir, '.credentials.json'), + JSON.stringify({ + claudeAiOauth: { + accessToken: 'token', + refreshToken: 'refresh', + }, + }) + ); + await fs.writeFile( + path.join(mockClaudeDir, 'settings.json'), + JSON.stringify({ theme: 'dark' }) + ); + await fs.writeFile( + path.join(mockClaudeDir, 'stats-cache.json'), + JSON.stringify({ dailyActivity: [{ date: '2025-01-15', messagesCount: 5 }] }) + ); + const projectsDir = path.join(mockClaudeDir, 'projects'); + await fs.mkdir(projectsDir, { recursive: true }); + await fs.mkdir(path.join(projectsDir, 'session-1')); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasCredentialsFile).toBe(true); + expect(indicators.hasSettingsFile).toBe(true); + expect(indicators.hasStatsCacheWithActivity).toBe(true); + expect(indicators.hasProjectsSessions).toBe(true); + expect(indicators.credentials?.hasOAuthToken).toBe(true); + }); + }); +}); From 0aef72540e5f9eff7919f5b2157ce7c5f1141204 Mon Sep 17 00:00:00 2001 From: Kacper Date: Mon, 2 Feb 2026 17:54:23 +0100 Subject: [PATCH 005/156] fix(auth): Enhance credential detection logic for OAuth - Updated getClaudeAuthIndicators() to ensure that empty or token-less credential files do not prevent the detection of valid credentials in subsequent paths. - Improved error handling for settings file readability checks, providing clearer feedback on file access issues. - Added unit tests to validate the new behavior, ensuring that the system continues to check all credential paths even when some files are empty or invalid. This change improves the robustness of the credential detection process and enhances user experience by allowing for more flexible credential management. --- libs/platform/src/system-paths.ts | 37 ++++++++--- .../tests/oauth-credential-detection.test.ts | 61 +++++++++++++------ 2 files changed, 72 insertions(+), 26 deletions(-) diff --git a/libs/platform/src/system-paths.ts b/libs/platform/src/system-paths.ts index fb5e6bd3..f1749464 100644 --- a/libs/platform/src/system-paths.ts +++ b/libs/platform/src/system-paths.ts @@ -1065,11 +1065,20 @@ export async function getClaudeAuthIndicators(): Promise { }; // Check settings file + // First check existence, then try to read to confirm it's actually readable try { if (await systemPathAccess(settingsPath)) { settingsFileCheck.exists = true; - settingsFileCheck.readable = true; - result.hasSettingsFile = true; + // Try to actually read the file to confirm read permissions + try { + await systemPathReadFile(settingsPath); + settingsFileCheck.readable = true; + result.hasSettingsFile = true; + } catch (readErr) { + // File exists but cannot be read (permission denied, etc.) + settingsFileCheck.readable = false; + settingsFileCheck.error = `Cannot read: ${readErr instanceof Error ? readErr.message : String(readErr)}`; + } } } catch (err) { settingsFileCheck.error = err instanceof Error ? err.message : String(err); @@ -1117,6 +1126,9 @@ export async function getClaudeAuthIndicators(): Promise { } // Check credentials files + // We iterate through all credential paths and only stop when we find a file + // that contains actual credentials (OAuth tokens or API keys). An empty or + // token-less file should not prevent checking subsequent credential paths. for (let i = 0; i < credentialPaths.length; i++) { const credPath = credentialPaths[i]; const credCheck = credentialFileChecks[i]; @@ -1126,18 +1138,27 @@ export async function getClaudeAuthIndicators(): Promise { credCheck.readable = true; try { const credentials = JSON.parse(content); - result.hasCredentialsFile = true; // Support multiple credential formats: // 1. Claude Code CLI format: { claudeAiOauth: { accessToken, refreshToken } } // 2. Legacy format: { oauth_token } or { access_token } // 3. API key format: { api_key } const hasClaudeOauth = !!credentials.claudeAiOauth?.accessToken; const hasLegacyOauth = !!(credentials.oauth_token || credentials.access_token); - result.credentials = { - hasOAuthToken: hasClaudeOauth || hasLegacyOauth, - hasApiKey: !!credentials.api_key, - }; - break; + const hasOAuthToken = hasClaudeOauth || hasLegacyOauth; + const hasApiKey = !!credentials.api_key; + + // Only consider this a valid credentials file if it actually contains tokens + // An empty JSON file ({}) or file without tokens should not stop us from + // checking subsequent credential paths + if (hasOAuthToken || hasApiKey) { + result.hasCredentialsFile = true; + result.credentials = { + hasOAuthToken, + hasApiKey, + }; + break; // Found valid credentials, stop searching + } + // File exists and is valid JSON but contains no tokens - continue checking other paths } catch (parseErr) { credCheck.error = `JSON parse error: ${parseErr instanceof Error ? parseErr.message : String(parseErr)}`; } diff --git a/libs/platform/tests/oauth-credential-detection.test.ts b/libs/platform/tests/oauth-credential-detection.test.ts index cf5a4705..6e445b22 100644 --- a/libs/platform/tests/oauth-credential-detection.test.ts +++ b/libs/platform/tests/oauth-credential-detection.test.ts @@ -173,10 +173,14 @@ describe('OAuth Credential Detection', () => { const { getClaudeAuthIndicators } = await import('../src/system-paths'); const indicators = await getClaudeAuthIndicators(); - expect(indicators.hasCredentialsFile).toBe(true); - expect(indicators.credentials).not.toBeNull(); - expect(indicators.credentials?.hasOAuthToken).toBe(false); - expect(indicators.credentials?.hasApiKey).toBe(false); + // Empty credentials file ({}) should NOT be treated as having credentials + // because it contains no actual tokens. This allows the system to continue + // checking subsequent credential paths that might have valid tokens. + expect(indicators.hasCredentialsFile).toBe(false); + expect(indicators.credentials).toBeNull(); + // But the file should still show as existing and readable in the checks + expect(indicators.checks.credentialFiles[0].exists).toBe(true); + expect(indicators.checks.credentialFiles[0].readable).toBe(true); }); it('should handle credentials file with null values', async () => { @@ -191,9 +195,10 @@ describe('OAuth Credential Detection', () => { const { getClaudeAuthIndicators } = await import('../src/system-paths'); const indicators = await getClaudeAuthIndicators(); - expect(indicators.hasCredentialsFile).toBe(true); - expect(indicators.credentials?.hasOAuthToken).toBe(false); - expect(indicators.credentials?.hasApiKey).toBe(false); + // File with all null values should NOT be treated as having credentials + // because null values are not valid tokens + expect(indicators.hasCredentialsFile).toBe(false); + expect(indicators.credentials).toBeNull(); }); it('should handle credentials with empty string values', async () => { @@ -210,10 +215,10 @@ describe('OAuth Credential Detection', () => { const { getClaudeAuthIndicators } = await import('../src/system-paths'); const indicators = await getClaudeAuthIndicators(); - expect(indicators.hasCredentialsFile).toBe(true); - // Empty strings should not be treated as valid credentials - expect(indicators.credentials?.hasOAuthToken).toBe(false); - expect(indicators.credentials?.hasApiKey).toBe(false); + // Empty strings should NOT be treated as having credentials + // This allows checking subsequent credential paths for valid tokens + expect(indicators.hasCredentialsFile).toBe(false); + expect(indicators.credentials).toBeNull(); }); it('should detect settings file presence', async () => { @@ -337,6 +342,27 @@ describe('OAuth Credential Detection', () => { expect(indicators.credentials?.hasOAuthToken).toBe(true); expect(indicators.credentials?.hasApiKey).toBe(false); }); + + it('should check second credentials file if first file has no tokens', async () => { + // Write empty/token-less content to .credentials.json (first path checked) + // This tests the bug fix: previously, an empty JSON file would stop the search + await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), JSON.stringify({})); + + // Write actual credentials to credentials.json (second path) + await fs.writeFile( + path.join(mockClaudeDir, 'credentials.json'), + JSON.stringify({ + api_key: 'sk-test-key-from-second-file', + }) + ); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + // Should find credentials in second file since first file has no tokens + expect(indicators.hasCredentialsFile).toBe(true); + expect(indicators.credentials?.hasApiKey).toBe(true); + }); }); describe('getCodexAuthIndicators', () => { @@ -585,9 +611,9 @@ describe('OAuth Credential Detection', () => { const { getClaudeAuthIndicators } = await import('../src/system-paths'); const indicators = await getClaudeAuthIndicators(); - expect(indicators.hasCredentialsFile).toBe(true); - expect(indicators.credentials?.hasOAuthToken).toBe(false); - expect(indicators.credentials?.hasApiKey).toBe(false); + // File with unexpected structure but no valid tokens should NOT be treated as having credentials + expect(indicators.hasCredentialsFile).toBe(false); + expect(indicators.credentials).toBeNull(); }); it('should handle array instead of object in credentials', async () => { @@ -598,10 +624,9 @@ describe('OAuth Credential Detection', () => { const { getClaudeAuthIndicators } = await import('../src/system-paths'); const indicators = await getClaudeAuthIndicators(); - // Array is valid JSON but wrong structure - should handle gracefully - expect(indicators.hasCredentialsFile).toBe(true); - expect(indicators.credentials?.hasOAuthToken).toBe(false); - expect(indicators.credentials?.hasApiKey).toBe(false); + // Array is valid JSON but wrong structure - no valid tokens, so not treated as credentials file + expect(indicators.hasCredentialsFile).toBe(false); + expect(indicators.credentials).toBeNull(); }); it('should handle numeric values in credential fields', async () => { From 3b361cb0b918b9e37b43c5136af68b7d91921a60 Mon Sep 17 00:00:00 2001 From: Kacper Date: Thu, 5 Feb 2026 22:17:55 +0100 Subject: [PATCH 006/156] chore: update Codex SDK to version 0.98.0 and add GPT-5.3-Codex model - Upgraded @openai/codex-sdk from version 0.77.0 to 0.98.0 in package-lock.json and package.json. - Introduced new model 'GPT-5.3-Codex' with enhanced capabilities in codex-models.ts and related files. - Updated descriptions for existing models to reflect their latest features and improvements. - Adjusted Codex model configuration and display to include the new model and its attributes. These changes enhance the Codex model offerings and ensure compatibility with the latest SDK version. --- apps/server/package.json | 2 +- apps/server/src/providers/codex-models.ts | 28 +++++++++++++------ .../providers/codex-model-configuration.tsx | 14 +++++++--- libs/platform/src/system-paths.ts | 24 ++++++++++++++-- libs/types/src/codex-models.ts | 16 ++++++++--- libs/types/src/model-display.ts | 17 ++++++++--- libs/types/src/model.ts | 11 +++++--- package-lock.json | 8 +++--- 8 files changed, 88 insertions(+), 32 deletions(-) diff --git a/apps/server/package.json b/apps/server/package.json index c9015aea..0b4deeac 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -34,7 +34,7 @@ "@automaker/utils": "1.0.0", "@github/copilot-sdk": "^0.1.16", "@modelcontextprotocol/sdk": "1.25.2", - "@openai/codex-sdk": "^0.77.0", + "@openai/codex-sdk": "^0.98.0", "cookie-parser": "1.4.7", "cors": "2.8.5", "dotenv": "17.2.3", diff --git a/apps/server/src/providers/codex-models.ts b/apps/server/src/providers/codex-models.ts index 141d5355..7840888b 100644 --- a/apps/server/src/providers/codex-models.ts +++ b/apps/server/src/providers/codex-models.ts @@ -19,12 +19,11 @@ const MAX_OUTPUT_16K = 16000; export const CODEX_MODELS: ModelDefinition[] = [ // ========== Recommended Codex Models ========== { - id: CODEX_MODEL_MAP.gpt52Codex, - name: 'GPT-5.2-Codex', - modelString: CODEX_MODEL_MAP.gpt52Codex, + id: CODEX_MODEL_MAP.gpt53Codex, + name: 'GPT-5.3-Codex', + modelString: CODEX_MODEL_MAP.gpt53Codex, provider: 'openai', - description: - 'Most advanced agentic coding model for complex software engineering (default for ChatGPT users).', + description: 'Latest frontier agentic coding model.', contextWindow: CONTEXT_WINDOW_256K, maxOutputTokens: MAX_OUTPUT_32K, supportsVision: true, @@ -33,12 +32,25 @@ export const CODEX_MODELS: ModelDefinition[] = [ default: true, hasReasoning: true, }, + { + id: CODEX_MODEL_MAP.gpt52Codex, + name: 'GPT-5.2-Codex', + modelString: CODEX_MODEL_MAP.gpt52Codex, + provider: 'openai', + description: 'Frontier agentic coding model.', + contextWindow: CONTEXT_WINDOW_256K, + maxOutputTokens: MAX_OUTPUT_32K, + supportsVision: true, + supportsTools: true, + tier: 'premium' as const, + hasReasoning: true, + }, { id: CODEX_MODEL_MAP.gpt51CodexMax, name: 'GPT-5.1-Codex-Max', modelString: CODEX_MODEL_MAP.gpt51CodexMax, provider: 'openai', - description: 'Optimized for long-horizon, agentic coding tasks in Codex.', + description: 'Codex-optimized flagship for deep and fast reasoning.', contextWindow: CONTEXT_WINDOW_256K, maxOutputTokens: MAX_OUTPUT_32K, supportsVision: true, @@ -51,7 +63,7 @@ export const CODEX_MODELS: ModelDefinition[] = [ name: 'GPT-5.1-Codex-Mini', modelString: CODEX_MODEL_MAP.gpt51CodexMini, provider: 'openai', - description: 'Smaller, more cost-effective version for faster workflows.', + description: 'Optimized for codex. Cheaper, faster, but less capable.', contextWindow: CONTEXT_WINDOW_128K, maxOutputTokens: MAX_OUTPUT_16K, supportsVision: true, @@ -66,7 +78,7 @@ export const CODEX_MODELS: ModelDefinition[] = [ name: 'GPT-5.2', modelString: CODEX_MODEL_MAP.gpt52, provider: 'openai', - description: 'Best general agentic model for tasks across industries and domains.', + description: 'Latest frontier model with improvements across knowledge, reasoning and coding.', contextWindow: CONTEXT_WINDOW_256K, maxOutputTokens: MAX_OUTPUT_32K, supportsVision: true, diff --git a/apps/ui/src/components/views/settings-view/providers/codex-model-configuration.tsx b/apps/ui/src/components/views/settings-view/providers/codex-model-configuration.tsx index a9d8c06e..de1d9555 100644 --- a/apps/ui/src/components/views/settings-view/providers/codex-model-configuration.tsx +++ b/apps/ui/src/components/views/settings-view/providers/codex-model-configuration.tsx @@ -27,25 +27,30 @@ interface CodexModelInfo { } const CODEX_MODEL_INFO: Record = { + 'codex-gpt-5.3-codex': { + id: 'codex-gpt-5.3-codex', + label: 'GPT-5.3-Codex', + description: 'Latest frontier agentic coding model', + }, 'codex-gpt-5.2-codex': { id: 'codex-gpt-5.2-codex', label: 'GPT-5.2-Codex', - description: 'Most advanced agentic coding model for complex software engineering', + description: 'Frontier agentic coding model', }, 'codex-gpt-5.1-codex-max': { id: 'codex-gpt-5.1-codex-max', label: 'GPT-5.1-Codex-Max', - description: 'Optimized for long-horizon, agentic coding tasks in Codex', + description: 'Codex-optimized flagship for deep and fast reasoning', }, 'codex-gpt-5.1-codex-mini': { id: 'codex-gpt-5.1-codex-mini', label: 'GPT-5.1-Codex-Mini', - description: 'Smaller, more cost-effective version for faster workflows', + description: 'Optimized for codex. Cheaper, faster, but less capable', }, 'codex-gpt-5.2': { id: 'codex-gpt-5.2', label: 'GPT-5.2', - description: 'Best general agentic model for tasks across industries and domains', + description: 'Latest frontier model with improvements across knowledge, reasoning and coding', }, 'codex-gpt-5.1': { id: 'codex-gpt-5.1', @@ -160,6 +165,7 @@ export function CodexModelConfiguration({ function supportsReasoningEffort(modelId: string): boolean { const reasoningModels = [ + 'codex-gpt-5.3-codex', 'codex-gpt-5.2-codex', 'codex-gpt-5.1-codex-max', 'codex-gpt-5.2', diff --git a/libs/platform/src/system-paths.ts b/libs/platform/src/system-paths.ts index 0d900dfa..ce1246eb 100644 --- a/libs/platform/src/system-paths.ts +++ b/libs/platform/src/system-paths.ts @@ -54,13 +54,19 @@ export function getClaudeCliPaths(): string[] { if (isWindows) { const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'); - return [ + const nvmSymlink = process.env.NVM_SYMLINK; + const paths = [ path.join(os.homedir(), '.local', 'bin', 'claude.exe'), path.join(appData, 'npm', 'claude.cmd'), path.join(appData, 'npm', 'claude'), path.join(appData, '.npm-global', 'bin', 'claude.cmd'), path.join(appData, '.npm-global', 'bin', 'claude'), ]; + // nvm4w (NVM for Windows) symlink path + if (nvmSymlink) { + paths.push(path.join(nvmSymlink, 'claude.cmd'), path.join(nvmSymlink, 'claude')); + } + return paths; } return [ @@ -130,7 +136,8 @@ export function getCodexCliPaths(): string[] { if (isWindows) { const appData = process.env.APPDATA || path.join(homeDir, 'AppData', 'Roaming'); const localAppData = process.env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local'); - return [ + const nvmSymlink = process.env.NVM_SYMLINK; + const paths = [ path.join(homeDir, '.local', 'bin', 'codex.exe'), path.join(appData, 'npm', 'codex.cmd'), path.join(appData, 'npm', 'codex'), @@ -142,6 +149,11 @@ export function getCodexCliPaths(): string[] { path.join(localAppData, 'pnpm', 'codex.cmd'), path.join(localAppData, 'pnpm', 'codex'), ]; + // nvm4w (NVM for Windows) symlink path + if (nvmSymlink) { + paths.push(path.join(nvmSymlink, 'codex.cmd'), path.join(nvmSymlink, 'codex')); + } + return paths; } // Include NVM bin paths for codex installed via npm global under NVM @@ -1126,7 +1138,8 @@ export function getOpenCodeCliPaths(): string[] { if (isWindows) { const appData = process.env.APPDATA || path.join(homeDir, 'AppData', 'Roaming'); const localAppData = process.env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local'); - return [ + const nvmSymlink = process.env.NVM_SYMLINK; + const paths = [ // OpenCode's default installation directory path.join(homeDir, '.opencode', 'bin', 'opencode.exe'), path.join(homeDir, '.local', 'bin', 'opencode.exe'), @@ -1143,6 +1156,11 @@ export function getOpenCodeCliPaths(): string[] { path.join(homeDir, 'go', 'bin', 'opencode.exe'), path.join(process.env.GOPATH || path.join(homeDir, 'go'), 'bin', 'opencode.exe'), ]; + // nvm4w (NVM for Windows) symlink path + if (nvmSymlink) { + paths.push(path.join(nvmSymlink, 'opencode.cmd'), path.join(nvmSymlink, 'opencode')); + } + return paths; } // Include NVM bin paths for opencode installed via npm global under NVM diff --git a/libs/types/src/codex-models.ts b/libs/types/src/codex-models.ts index cf4db0ea..934218ea 100644 --- a/libs/types/src/codex-models.ts +++ b/libs/types/src/codex-models.ts @@ -6,6 +6,7 @@ * IMPORTANT: All Codex models use 'codex-' prefix to distinguish from Cursor CLI models */ export type CodexModelId = + | 'codex-gpt-5.3-codex' | 'codex-gpt-5.2-codex' | 'codex-gpt-5.1-codex-max' | 'codex-gpt-5.1-codex-mini' @@ -29,31 +30,38 @@ export interface CodexModelConfig { * All keys use 'codex-' prefix to distinguish from Cursor CLI models */ export const CODEX_MODEL_CONFIG_MAP: Record = { + 'codex-gpt-5.3-codex': { + id: 'codex-gpt-5.3-codex', + label: 'GPT-5.3-Codex', + description: 'Latest frontier agentic coding model', + hasThinking: true, + supportsVision: true, + }, 'codex-gpt-5.2-codex': { id: 'codex-gpt-5.2-codex', label: 'GPT-5.2-Codex', - description: 'Most advanced agentic coding model for complex software engineering', + description: 'Frontier agentic coding model', hasThinking: true, supportsVision: true, }, 'codex-gpt-5.1-codex-max': { id: 'codex-gpt-5.1-codex-max', label: 'GPT-5.1-Codex-Max', - description: 'Optimized for long-horizon, agentic coding tasks in Codex', + description: 'Codex-optimized flagship for deep and fast reasoning', hasThinking: true, supportsVision: true, }, 'codex-gpt-5.1-codex-mini': { id: 'codex-gpt-5.1-codex-mini', label: 'GPT-5.1-Codex-Mini', - description: 'Smaller, more cost-effective version for faster workflows', + description: 'Optimized for codex. Cheaper, faster, but less capable', hasThinking: false, supportsVision: true, }, 'codex-gpt-5.2': { id: 'codex-gpt-5.2', label: 'GPT-5.2 (Codex)', - description: 'Best general agentic model for tasks across industries and domains via Codex', + description: 'Latest frontier model with improvements across knowledge, reasoning and coding', hasThinking: true, supportsVision: true, }, diff --git a/libs/types/src/model-display.ts b/libs/types/src/model-display.ts index 28670328..08eaf208 100644 --- a/libs/types/src/model-display.ts +++ b/libs/types/src/model-display.ts @@ -72,10 +72,18 @@ export const CLAUDE_MODELS: ModelOption[] = [ * Official models from https://developers.openai.com/codex/models/ */ export const CODEX_MODELS: (ModelOption & { hasReasoning?: boolean })[] = [ + { + id: CODEX_MODEL_MAP.gpt53Codex, + label: 'GPT-5.3-Codex', + description: 'Latest frontier agentic coding model.', + badge: 'Premium', + provider: 'codex', + hasReasoning: true, + }, { id: CODEX_MODEL_MAP.gpt52Codex, label: 'GPT-5.2-Codex', - description: 'Most advanced agentic coding model for complex software engineering.', + description: 'Frontier agentic coding model.', badge: 'Premium', provider: 'codex', hasReasoning: true, @@ -83,7 +91,7 @@ export const CODEX_MODELS: (ModelOption & { hasReasoning?: boolean })[] = [ { id: CODEX_MODEL_MAP.gpt51CodexMax, label: 'GPT-5.1-Codex-Max', - description: 'Optimized for long-horizon, agentic coding tasks in Codex.', + description: 'Codex-optimized flagship for deep and fast reasoning.', badge: 'Premium', provider: 'codex', hasReasoning: true, @@ -91,7 +99,7 @@ export const CODEX_MODELS: (ModelOption & { hasReasoning?: boolean })[] = [ { id: CODEX_MODEL_MAP.gpt51CodexMini, label: 'GPT-5.1-Codex-Mini', - description: 'Smaller, more cost-effective version for faster workflows.', + description: 'Optimized for codex. Cheaper, faster, but less capable.', badge: 'Speed', provider: 'codex', hasReasoning: false, @@ -99,7 +107,7 @@ export const CODEX_MODELS: (ModelOption & { hasReasoning?: boolean })[] = [ { id: CODEX_MODEL_MAP.gpt52, label: 'GPT-5.2', - description: 'Best general agentic model for tasks across industries and domains.', + description: 'Latest frontier model with improvements across knowledge, reasoning and coding.', badge: 'Balanced', provider: 'codex', hasReasoning: true, @@ -211,6 +219,7 @@ export function getModelDisplayName(model: ModelAlias | string): string { haiku: 'Claude Haiku', sonnet: 'Claude Sonnet', opus: 'Claude Opus', + [CODEX_MODEL_MAP.gpt53Codex]: 'GPT-5.3-Codex', [CODEX_MODEL_MAP.gpt52Codex]: 'GPT-5.2-Codex', [CODEX_MODEL_MAP.gpt51CodexMax]: 'GPT-5.1-Codex-Max', [CODEX_MODEL_MAP.gpt51CodexMini]: 'GPT-5.1-Codex-Mini', diff --git a/libs/types/src/model.ts b/libs/types/src/model.ts index 5538989e..b6b90da9 100644 --- a/libs/types/src/model.ts +++ b/libs/types/src/model.ts @@ -50,15 +50,17 @@ export const LEGACY_CLAUDE_ALIAS_MAP: Record = { */ export const CODEX_MODEL_MAP = { // Recommended Codex-specific models - /** Most advanced agentic coding model for complex software engineering (default for ChatGPT users) */ + /** Latest frontier agentic coding model */ + gpt53Codex: 'codex-gpt-5.3-codex', + /** Frontier agentic coding model */ gpt52Codex: 'codex-gpt-5.2-codex', - /** Optimized for long-horizon, agentic coding tasks in Codex */ + /** Codex-optimized flagship for deep and fast reasoning */ gpt51CodexMax: 'codex-gpt-5.1-codex-max', - /** Smaller, more cost-effective version for faster workflows */ + /** Optimized for codex. Cheaper, faster, but less capable */ gpt51CodexMini: 'codex-gpt-5.1-codex-mini', // General-purpose GPT models (also available in Codex) - /** Best general agentic model for tasks across industries and domains */ + /** Latest frontier model with improvements across knowledge, reasoning and coding */ gpt52: 'codex-gpt-5.2', /** Great for coding and agentic tasks across domains */ gpt51: 'codex-gpt-5.1', @@ -71,6 +73,7 @@ export const CODEX_MODEL_IDS = Object.values(CODEX_MODEL_MAP); * These models can use reasoning.effort parameter */ export const REASONING_CAPABLE_MODELS = new Set([ + CODEX_MODEL_MAP.gpt53Codex, CODEX_MODEL_MAP.gpt52Codex, CODEX_MODEL_MAP.gpt51CodexMax, CODEX_MODEL_MAP.gpt52, diff --git a/package-lock.json b/package-lock.json index 9f4f4d28..0649982d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,7 +45,7 @@ "@automaker/utils": "1.0.0", "@github/copilot-sdk": "^0.1.16", "@modelcontextprotocol/sdk": "1.25.2", - "@openai/codex-sdk": "^0.77.0", + "@openai/codex-sdk": "^0.98.0", "cookie-parser": "1.4.7", "cors": "2.8.5", "dotenv": "17.2.3", @@ -3949,9 +3949,9 @@ } }, "node_modules/@openai/codex-sdk": { - "version": "0.77.0", - "resolved": "https://registry.npmjs.org/@openai/codex-sdk/-/codex-sdk-0.77.0.tgz", - "integrity": "sha512-bvJQ4dASnZ7jgfxmseViQwdRupHxs0TwHSZFeYB0gpdOAXnWwDWdGJRCMyphLSHwExRp27JNOk7EBFVmZRBanQ==", + "version": "0.98.0", + "resolved": "https://registry.npmjs.org/@openai/codex-sdk/-/codex-sdk-0.98.0.tgz", + "integrity": "sha512-TbPgrBpuSNMJyOXys0HNsh6UoP5VIHu1fVh2KDdACi5XyB0vuPtzBZC+qOsxHz7WXEQPFlomPLyxS6JnE5Okmg==", "license": "Apache-2.0", "engines": { "node": ">=18" From 835ffe31853dde25d3676ca6dd4a100268f39570 Mon Sep 17 00:00:00 2001 From: Kacper Date: Thu, 5 Feb 2026 22:43:22 +0100 Subject: [PATCH 007/156] feat: update Claude model to Opus 4.6 and enhance adaptive thinking support - Changed model identifier from `claude-opus-4-5-20251101` to `claude-opus-4-6` across various files, including documentation and code references. - Updated the SDK to support adaptive thinking for Opus 4.6, allowing the model to determine its own reasoning depth. - Enhanced the thinking level options to include 'adaptive' and adjusted related components to reflect this change. - Updated tests to ensure compatibility with the new model and its features. These changes improve the model's capabilities and user experience by leveraging adaptive reasoning. --- CLAUDE.md | 2 +- apps/server/package.json | 2 +- apps/server/src/lib/sdk-options.ts | 18 ++++++++++- apps/server/src/providers/claude-provider.ts | 17 ++++++---- apps/server/src/providers/provider-factory.ts | 2 +- .../tests/unit/lib/model-resolver.test.ts | 4 +-- .../server/tests/unit/lib/sdk-options.test.ts | 24 ++++++++++++++ .../unit/providers/claude-provider.test.ts | 32 +++++++++---------- .../unit/providers/provider-factory.test.ts | 6 ++-- apps/ui/docs/AGENT_ARCHITECTURE.md | 2 +- .../board-view/dialogs/add-feature-dialog.tsx | 16 +++++++++- .../board-view/shared/model-constants.ts | 10 +++++- .../shared/thinking-level-selector.tsx | 14 ++++++-- .../model-defaults/phase-model-selector.tsx | 18 ++++++++--- apps/ui/src/lib/agent-context-parser.ts | 3 +- docs/llm-shared-packages.md | 2 +- docs/server/providers.md | 6 ++-- docs/server/utilities.md | 12 +++---- libs/model-resolver/README.md | 12 +++---- libs/model-resolver/tests/resolver.test.ts | 4 +-- libs/types/src/index.ts | 2 ++ libs/types/src/model-display.ts | 2 ++ libs/types/src/model.ts | 6 ++-- libs/types/src/settings.ts | 23 ++++++++++++- package-lock.json | 10 +++--- 25 files changed, 178 insertions(+), 71 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 128cd8d7..84dd1fbb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -161,7 +161,7 @@ Use `resolveModelString()` from `@automaker/model-resolver` to convert model ali - `haiku` β†’ `claude-haiku-4-5` - `sonnet` β†’ `claude-sonnet-4-20250514` -- `opus` β†’ `claude-opus-4-5-20251101` +- `opus` β†’ `claude-opus-4-6` ## Environment Variables diff --git a/apps/server/package.json b/apps/server/package.json index 0b4deeac..ed005c54 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -24,7 +24,7 @@ "test:unit": "vitest run tests/unit" }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "0.1.76", + "@anthropic-ai/claude-agent-sdk": "0.2.32", "@automaker/dependency-resolver": "1.0.0", "@automaker/git-utils": "1.0.0", "@automaker/model-resolver": "1.0.0", diff --git a/apps/server/src/lib/sdk-options.ts b/apps/server/src/lib/sdk-options.ts index cc1df2f5..674350a5 100644 --- a/apps/server/src/lib/sdk-options.ts +++ b/apps/server/src/lib/sdk-options.ts @@ -253,11 +253,27 @@ function buildMcpOptions(config: CreateSdkOptionsConfig): McpOptions { /** * Build thinking options for SDK configuration. * Converts ThinkingLevel to maxThinkingTokens for the Claude SDK. + * For adaptive thinking (Opus 4.6), omits maxThinkingTokens to let the model + * decide its own reasoning depth. * * @param thinkingLevel - The thinking level to convert - * @returns Object with maxThinkingTokens if thinking is enabled + * @returns Object with maxThinkingTokens if thinking is enabled with a budget */ function buildThinkingOptions(thinkingLevel?: ThinkingLevel): Partial { + if (!thinkingLevel || thinkingLevel === 'none') { + return {}; + } + + // Adaptive thinking (Opus 4.6): don't set maxThinkingTokens + // The model will use adaptive thinking by default + if (thinkingLevel === 'adaptive') { + logger.debug( + `buildThinkingOptions: thinkingLevel="adaptive" -> no maxThinkingTokens (model decides)` + ); + return {}; + } + + // Manual budget-based thinking for Haiku/Sonnet const maxThinkingTokens = getThinkingTokenBudget(thinkingLevel); logger.debug( `buildThinkingOptions: thinkingLevel="${thinkingLevel}" -> maxThinkingTokens=${maxThinkingTokens}` diff --git a/apps/server/src/providers/claude-provider.ts b/apps/server/src/providers/claude-provider.ts index cfb59093..78a0a0c7 100644 --- a/apps/server/src/providers/claude-provider.ts +++ b/apps/server/src/providers/claude-provider.ts @@ -219,8 +219,11 @@ export class ClaudeProvider extends BaseProvider { // claudeCompatibleProvider takes precedence over claudeApiProfile const providerConfig = claudeCompatibleProvider || claudeApiProfile; - // Convert thinking level to token budget - const maxThinkingTokens = getThinkingTokenBudget(thinkingLevel); + // Build thinking configuration + // Adaptive thinking (Opus 4.6): don't set maxThinkingTokens, model uses adaptive by default + // Manual thinking (Haiku/Sonnet): use budget_tokens + const maxThinkingTokens = + thinkingLevel === 'adaptive' ? undefined : getThinkingTokenBudget(thinkingLevel); // Build Claude SDK options const sdkOptions: Options = { @@ -349,13 +352,13 @@ export class ClaudeProvider extends BaseProvider { getAvailableModels(): ModelDefinition[] { const models = [ { - id: 'claude-opus-4-5-20251101', - name: 'Claude Opus 4.5', - modelString: 'claude-opus-4-5-20251101', + id: 'claude-opus-4-6', + name: 'Claude Opus 4.6', + modelString: 'claude-opus-4-6', provider: 'anthropic', - description: 'Most capable Claude model', + description: 'Most capable Claude model with adaptive thinking', contextWindow: 200000, - maxOutputTokens: 16000, + maxOutputTokens: 128000, supportsVision: true, supportsTools: true, tier: 'premium' as const, diff --git a/apps/server/src/providers/provider-factory.ts b/apps/server/src/providers/provider-factory.ts index 1e91760f..a6dff69e 100644 --- a/apps/server/src/providers/provider-factory.ts +++ b/apps/server/src/providers/provider-factory.ts @@ -103,7 +103,7 @@ export class ProviderFactory { /** * Get the appropriate provider for a given model ID * - * @param modelId Model identifier (e.g., "claude-opus-4-5-20251101", "cursor-gpt-4o", "cursor-auto") + * @param modelId Model identifier (e.g., "claude-opus-4-6", "cursor-gpt-4o", "cursor-auto") * @param options Optional settings * @param options.throwOnDisconnected Throw error if provider is disconnected (default: true) * @returns Provider instance for the model diff --git a/apps/server/tests/unit/lib/model-resolver.test.ts b/apps/server/tests/unit/lib/model-resolver.test.ts index c1bff78d..65e3115d 100644 --- a/apps/server/tests/unit/lib/model-resolver.test.ts +++ b/apps/server/tests/unit/lib/model-resolver.test.ts @@ -35,7 +35,7 @@ describe('model-resolver.ts', () => { it("should resolve 'opus' alias to full model string", () => { const result = resolveModelString('opus'); - expect(result).toBe('claude-opus-4-5-20251101'); + expect(result).toBe('claude-opus-4-6'); expect(consoleSpy.log).toHaveBeenCalledWith( expect.stringContaining('Migrated legacy ID: "opus" -> "claude-opus"') ); @@ -117,7 +117,7 @@ describe('model-resolver.ts', () => { describe('getEffectiveModel', () => { it('should prioritize explicit model over session and default', () => { const result = getEffectiveModel('opus', 'haiku', 'gpt-5.2'); - expect(result).toBe('claude-opus-4-5-20251101'); + expect(result).toBe('claude-opus-4-6'); }); it('should use session model when explicit is not provided', () => { diff --git a/apps/server/tests/unit/lib/sdk-options.test.ts b/apps/server/tests/unit/lib/sdk-options.test.ts index 029cd8fa..69d69794 100644 --- a/apps/server/tests/unit/lib/sdk-options.test.ts +++ b/apps/server/tests/unit/lib/sdk-options.test.ts @@ -491,5 +491,29 @@ describe('sdk-options.ts', () => { expect(options.maxThinkingTokens).toBeUndefined(); }); }); + + describe('adaptive thinking for Opus 4.6', () => { + it('should not set maxThinkingTokens for adaptive thinking (model decides)', async () => { + const { createAutoModeOptions } = await import('@/lib/sdk-options.js'); + + const options = createAutoModeOptions({ + cwd: '/test/path', + thinkingLevel: 'adaptive', + }); + + expect(options.maxThinkingTokens).toBeUndefined(); + }); + + it('should not include maxThinkingTokens when thinkingLevel is "none"', async () => { + const { createAutoModeOptions } = await import('@/lib/sdk-options.js'); + + const options = createAutoModeOptions({ + cwd: '/test/path', + thinkingLevel: 'none', + }); + + expect(options.maxThinkingTokens).toBeUndefined(); + }); + }); }); }); diff --git a/apps/server/tests/unit/providers/claude-provider.test.ts b/apps/server/tests/unit/providers/claude-provider.test.ts index c3f83f8f..7df211ef 100644 --- a/apps/server/tests/unit/providers/claude-provider.test.ts +++ b/apps/server/tests/unit/providers/claude-provider.test.ts @@ -39,7 +39,7 @@ describe('claude-provider.ts', () => { const generator = provider.executeQuery({ prompt: 'Hello', - model: 'claude-opus-4-5-20251101', + model: 'claude-opus-4-6', cwd: '/test', }); @@ -59,7 +59,7 @@ describe('claude-provider.ts', () => { const generator = provider.executeQuery({ prompt: 'Test prompt', - model: 'claude-opus-4-5-20251101', + model: 'claude-opus-4-6', cwd: '/test/dir', systemPrompt: 'You are helpful', maxTurns: 10, @@ -71,7 +71,7 @@ describe('claude-provider.ts', () => { expect(sdk.query).toHaveBeenCalledWith({ prompt: 'Test prompt', options: expect.objectContaining({ - model: 'claude-opus-4-5-20251101', + model: 'claude-opus-4-6', systemPrompt: 'You are helpful', maxTurns: 10, cwd: '/test/dir', @@ -91,7 +91,7 @@ describe('claude-provider.ts', () => { const generator = provider.executeQuery({ prompt: 'Test', - model: 'claude-opus-4-5-20251101', + model: 'claude-opus-4-6', cwd: '/test', }); @@ -116,7 +116,7 @@ describe('claude-provider.ts', () => { const generator = provider.executeQuery({ prompt: 'Test', - model: 'claude-opus-4-5-20251101', + model: 'claude-opus-4-6', cwd: '/test', abortController, }); @@ -145,7 +145,7 @@ describe('claude-provider.ts', () => { const generator = provider.executeQuery({ prompt: 'Current message', - model: 'claude-opus-4-5-20251101', + model: 'claude-opus-4-6', cwd: '/test', conversationHistory, sdkSessionId: 'test-session-id', @@ -176,7 +176,7 @@ describe('claude-provider.ts', () => { const generator = provider.executeQuery({ prompt: arrayPrompt as any, - model: 'claude-opus-4-5-20251101', + model: 'claude-opus-4-6', cwd: '/test', }); @@ -196,7 +196,7 @@ describe('claude-provider.ts', () => { const generator = provider.executeQuery({ prompt: 'Test', - model: 'claude-opus-4-5-20251101', + model: 'claude-opus-4-6', cwd: '/test', }); @@ -222,7 +222,7 @@ describe('claude-provider.ts', () => { const generator = provider.executeQuery({ prompt: 'Test', - model: 'claude-opus-4-5-20251101', + model: 'claude-opus-4-6', cwd: '/test', }); @@ -286,7 +286,7 @@ describe('claude-provider.ts', () => { const generator = provider.executeQuery({ prompt: 'Test', - model: 'claude-opus-4-5-20251101', + model: 'claude-opus-4-6', cwd: '/test', }); @@ -313,7 +313,7 @@ describe('claude-provider.ts', () => { const generator = provider.executeQuery({ prompt: 'Test', - model: 'claude-opus-4-5-20251101', + model: 'claude-opus-4-6', cwd: '/test', }); @@ -341,7 +341,7 @@ describe('claude-provider.ts', () => { const generator = provider.executeQuery({ prompt: 'Test', - model: 'claude-opus-4-5-20251101', + model: 'claude-opus-4-6', cwd: '/test', }); @@ -366,12 +366,12 @@ describe('claude-provider.ts', () => { expect(models).toHaveLength(4); }); - it('should include Claude Opus 4.5', () => { + it('should include Claude Opus 4.6', () => { const models = provider.getAvailableModels(); - const opus = models.find((m) => m.id === 'claude-opus-4-5-20251101'); + const opus = models.find((m) => m.id === 'claude-opus-4-6'); expect(opus).toBeDefined(); - expect(opus?.name).toBe('Claude Opus 4.5'); + expect(opus?.name).toBe('Claude Opus 4.6'); expect(opus?.provider).toBe('anthropic'); }); @@ -400,7 +400,7 @@ describe('claude-provider.ts', () => { it('should mark Opus as default', () => { const models = provider.getAvailableModels(); - const opus = models.find((m) => m.id === 'claude-opus-4-5-20251101'); + const opus = models.find((m) => m.id === 'claude-opus-4-6'); expect(opus?.default).toBe(true); }); diff --git a/apps/server/tests/unit/providers/provider-factory.test.ts b/apps/server/tests/unit/providers/provider-factory.test.ts index fbf01e90..b9aef928 100644 --- a/apps/server/tests/unit/providers/provider-factory.test.ts +++ b/apps/server/tests/unit/providers/provider-factory.test.ts @@ -54,8 +54,8 @@ describe('provider-factory.ts', () => { describe('getProviderForModel', () => { describe('Claude models (claude-* prefix)', () => { - it('should return ClaudeProvider for claude-opus-4-5-20251101', () => { - const provider = ProviderFactory.getProviderForModel('claude-opus-4-5-20251101'); + it('should return ClaudeProvider for claude-opus-4-6', () => { + const provider = ProviderFactory.getProviderForModel('claude-opus-4-6'); expect(provider).toBeInstanceOf(ClaudeProvider); }); @@ -70,7 +70,7 @@ describe('provider-factory.ts', () => { }); it('should be case-insensitive for claude models', () => { - const provider = ProviderFactory.getProviderForModel('CLAUDE-OPUS-4-5-20251101'); + const provider = ProviderFactory.getProviderForModel('CLAUDE-OPUS-4-6'); expect(provider).toBeInstanceOf(ClaudeProvider); }); }); diff --git a/apps/ui/docs/AGENT_ARCHITECTURE.md b/apps/ui/docs/AGENT_ARCHITECTURE.md index 4c9f0d11..f5c374c4 100644 --- a/apps/ui/docs/AGENT_ARCHITECTURE.md +++ b/apps/ui/docs/AGENT_ARCHITECTURE.md @@ -199,7 +199,7 @@ The agent is configured with: ```javascript { - model: "claude-opus-4-5-20251101", + model: "claude-opus-4-6", maxTurns: 20, cwd: workingDirectory, allowedTools: [ diff --git a/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx index b8dd8776..2dbf0808 100644 --- a/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx @@ -264,7 +264,21 @@ export function AddFeatureDialog({ }, [planningMode]); const handleModelChange = (entry: PhaseModelEntry) => { - setModelEntry(entry); + // Normalize thinking level when switching between adaptive and non-adaptive models + const isNewModelAdaptive = + entry.model === 'claude-opus' || + (typeof entry.model === 'string' && entry.model.includes('opus-4-6')); + const currentLevel = entry.thinkingLevel || 'none'; + + if (isNewModelAdaptive && currentLevel !== 'none' && currentLevel !== 'adaptive') { + // Switching TO Opus 4.6 with a manual level -> auto-switch to 'adaptive' + setModelEntry({ ...entry, thinkingLevel: 'adaptive' }); + } else if (!isNewModelAdaptive && currentLevel === 'adaptive') { + // Switching FROM Opus 4.6 with adaptive -> auto-switch to 'high' + setModelEntry({ ...entry, thinkingLevel: 'high' }); + } else { + setModelEntry(entry); + } }; const buildFeatureData = (): FeatureData | null => { diff --git a/apps/ui/src/components/views/board-view/shared/model-constants.ts b/apps/ui/src/components/views/board-view/shared/model-constants.ts index c56ad46a..2816e556 100644 --- a/apps/ui/src/components/views/board-view/shared/model-constants.ts +++ b/apps/ui/src/components/views/board-view/shared/model-constants.ts @@ -167,7 +167,14 @@ export const ALL_MODELS: ModelOption[] = [ ...COPILOT_MODELS, ]; -export const THINKING_LEVELS: ThinkingLevel[] = ['none', 'low', 'medium', 'high', 'ultrathink']; +export const THINKING_LEVELS: ThinkingLevel[] = [ + 'none', + 'low', + 'medium', + 'high', + 'ultrathink', + 'adaptive', +]; export const THINKING_LEVEL_LABELS: Record = { none: 'None', @@ -175,6 +182,7 @@ export const THINKING_LEVEL_LABELS: Record = { medium: 'Med', high: 'High', ultrathink: 'Ultra', + adaptive: 'Adaptive', }; /** diff --git a/apps/ui/src/components/views/board-view/shared/thinking-level-selector.tsx b/apps/ui/src/components/views/board-view/shared/thinking-level-selector.tsx index 74b791a3..3e111a31 100644 --- a/apps/ui/src/components/views/board-view/shared/thinking-level-selector.tsx +++ b/apps/ui/src/components/views/board-view/shared/thinking-level-selector.tsx @@ -2,19 +2,25 @@ import { Label } from '@/components/ui/label'; import { Brain } from 'lucide-react'; import { cn } from '@/lib/utils'; import { ThinkingLevel } from '@/store/app-store'; -import { THINKING_LEVELS, THINKING_LEVEL_LABELS } from './model-constants'; +import { THINKING_LEVEL_LABELS } from './model-constants'; +import { getThinkingLevelsForModel } from '@automaker/types'; interface ThinkingLevelSelectorProps { selectedLevel: ThinkingLevel; onLevelSelect: (level: ThinkingLevel) => void; testIdPrefix?: string; + /** Optional model ID to filter available thinking levels (e.g., Opus 4.6 only shows None/Adaptive) */ + model?: string; } export function ThinkingLevelSelector({ selectedLevel, onLevelSelect, testIdPrefix = 'thinking-level', + model, }: ThinkingLevelSelectorProps) { + const levels = model ? getThinkingLevelsForModel(model) : getThinkingLevelsForModel(''); + return (
- {THINKING_LEVELS.map((level) => ( + {levels.map((level) => (
); diff --git a/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx b/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx index 20420388..25424fa6 100644 --- a/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx +++ b/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx @@ -21,6 +21,7 @@ import { isGroupSelected, getSelectedVariant, codexModelHasThinking, + getThinkingLevelsForModel, } from '@automaker/types'; import { CLAUDE_MODELS, @@ -28,7 +29,6 @@ import { OPENCODE_MODELS, GEMINI_MODELS, COPILOT_MODELS, - THINKING_LEVELS, THINKING_LEVEL_LABELS, REASONING_EFFORT_LEVELS, REASONING_EFFORT_LABELS, @@ -1296,7 +1296,9 @@ export function PhaseModelSelector({
Thinking Level
- {THINKING_LEVELS.map((level) => ( + {getThinkingLevelsForModel( + model.mapsToClaudeModel === 'opus' ? 'claude-opus' : '' + ).map((level) => (
{isSelected && currentThinking === level && ( @@ -1402,7 +1405,9 @@ export function PhaseModelSelector({
Thinking Level
- {THINKING_LEVELS.map((level) => ( + {getThinkingLevelsForModel( + model.mapsToClaudeModel === 'opus' ? 'claude-opus' : '' + ).map((level) => ( - {feature.planSpec?.content && onViewPlan && ( + {!isCurrentAutoTask && + (feature.status === 'backlog' || + feature.status === 'interrupted' || + feature.status === 'ready') && ( + <> - )} - {onImplement && ( - - )} - - )} + {feature.planSpec?.content && onViewPlan && ( + + )} + {onImplement && ( + + )} + + )} ); }); diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx index 793c3191..bdf028b9 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx @@ -126,35 +126,39 @@ export const CardHeaderSection = memo(function CardHeaderSection({ )} - {/* Backlog header */} - {!isCurrentAutoTask && !isSelectionMode && feature.status === 'backlog' && ( -
- - -
- )} + {/* Backlog header (also handles 'interrupted' and 'ready' statuses that display in backlog column) */} + {!isCurrentAutoTask && + !isSelectionMode && + (feature.status === 'backlog' || + feature.status === 'interrupted' || + feature.status === 'ready') && ( +
+ + +
+ )} {/* Waiting approval / Verified header */} {!isCurrentAutoTask && diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx index a332f305..f6725a7d 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx @@ -121,6 +121,8 @@ export const KanbanCard = memo(function KanbanCard({ const isDraggable = !isSelectionMode && (feature.status === 'backlog' || + feature.status === 'interrupted' || + feature.status === 'ready' || feature.status === 'waiting_approval' || feature.status === 'verified' || feature.status.startsWith('pipeline_') || From 7765a12868a4a859f8ec912d7f54d8bca0e43761 Mon Sep 17 00:00:00 2001 From: eclipxe Date: Tue, 20 Jan 2026 14:34:15 -0800 Subject: [PATCH 076/156] Feat: Add z.ai usage tracking --- apps/server/src/index.ts | 4 + apps/server/src/routes/zai/index.ts | 179 +++++++++ apps/server/src/services/settings-service.ts | 1 + apps/server/src/services/zai-usage-service.ts | 375 ++++++++++++++++++ apps/ui/src/components/ui/provider-icon.tsx | 8 +- apps/ui/src/components/usage-popover.tsx | 254 ++++++++++-- .../views/board-view/board-header.tsx | 11 +- .../views/board-view/header-mobile-menu.tsx | 12 +- .../views/board-view/mobile-usage-bar.tsx | 119 +++++- .../api-keys/hooks/use-api-key-management.ts | 100 ++++- apps/ui/src/config/api-providers.ts | 38 +- apps/ui/src/hooks/queries/index.ts | 2 +- apps/ui/src/hooks/queries/use-usage.ts | 37 +- apps/ui/src/hooks/use-provider-auth-init.ts | 50 ++- apps/ui/src/lib/electron.ts | 56 ++- apps/ui/src/lib/http-api-client.ts | 61 +++ apps/ui/src/lib/query-keys.ts | 2 + apps/ui/src/store/app-store.ts | 14 + apps/ui/src/store/setup-store.ts | 26 ++ apps/ui/src/store/types/settings-types.ts | 1 + apps/ui/src/store/types/state-types.ts | 9 +- apps/ui/src/store/types/usage-types.ts | 24 ++ libs/types/src/settings.ts | 3 + 23 files changed, 1331 insertions(+), 55 deletions(-) create mode 100644 apps/server/src/routes/zai/index.ts create mode 100644 apps/server/src/services/zai-usage-service.ts diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 85ff0145..acff315e 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -66,6 +66,8 @@ import { createCodexRoutes } from './routes/codex/index.js'; import { CodexUsageService } from './services/codex-usage-service.js'; import { CodexAppServerService } from './services/codex-app-server-service.js'; import { CodexModelCacheService } from './services/codex-model-cache-service.js'; +import { createZaiRoutes } from './routes/zai/index.js'; +import { ZaiUsageService } from './services/zai-usage-service.js'; import { createGitHubRoutes } from './routes/github/index.js'; import { createContextRoutes } from './routes/context/index.js'; import { createBacklogPlanRoutes } from './routes/backlog-plan/index.js'; @@ -326,6 +328,7 @@ const claudeUsageService = new ClaudeUsageService(); const codexAppServerService = new CodexAppServerService(); const codexModelCacheService = new CodexModelCacheService(DATA_DIR, codexAppServerService); const codexUsageService = new CodexUsageService(codexAppServerService); +const zaiUsageService = new ZaiUsageService(); const mcpTestService = new MCPTestService(settingsService); const ideationService = new IdeationService(events, settingsService, featureLoader); @@ -434,6 +437,7 @@ app.use('/api/terminal', createTerminalRoutes()); app.use('/api/settings', createSettingsRoutes(settingsService)); app.use('/api/claude', createClaudeRoutes(claudeUsageService)); app.use('/api/codex', createCodexRoutes(codexUsageService, codexModelCacheService)); +app.use('/api/zai', createZaiRoutes(zaiUsageService, settingsService)); app.use('/api/github', createGitHubRoutes(events, settingsService)); app.use('/api/context', createContextRoutes(settingsService)); app.use('/api/backlog-plan', createBacklogPlanRoutes(events, settingsService)); diff --git a/apps/server/src/routes/zai/index.ts b/apps/server/src/routes/zai/index.ts new file mode 100644 index 00000000..baf84e19 --- /dev/null +++ b/apps/server/src/routes/zai/index.ts @@ -0,0 +1,179 @@ +import { Router, Request, Response } from 'express'; +import { ZaiUsageService } from '../../services/zai-usage-service.js'; +import type { SettingsService } from '../../services/settings-service.js'; +import { createLogger } from '@automaker/utils'; + +const logger = createLogger('Zai'); + +export function createZaiRoutes( + usageService: ZaiUsageService, + settingsService: SettingsService +): Router { + const router = Router(); + + // Initialize z.ai API token from credentials on startup + (async () => { + try { + const credentials = await settingsService.getCredentials(); + if (credentials.apiKeys?.zai) { + usageService.setApiToken(credentials.apiKeys.zai); + logger.info('[init] Loaded z.ai API key from credentials'); + } + } catch (error) { + logger.error('[init] Failed to load z.ai API key from credentials:', error); + } + })(); + + // Get current usage (fetches from z.ai API) + router.get('/usage', async (_req: Request, res: Response) => { + try { + // Check if z.ai API is configured + const isAvailable = usageService.isAvailable(); + if (!isAvailable) { + // Use a 200 + error payload so the UI doesn't interpret it as session auth error + res.status(200).json({ + error: 'z.ai API not configured', + message: 'Set Z_AI_API_KEY environment variable to enable z.ai usage tracking', + }); + return; + } + + const usage = await usageService.fetchUsageData(); + res.json(usage); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + + if (message.includes('not configured') || message.includes('API token')) { + res.status(200).json({ + error: 'API token required', + message: 'Set Z_AI_API_KEY environment variable to enable z.ai usage tracking', + }); + } else if (message.includes('failed') || message.includes('request')) { + res.status(200).json({ + error: 'API request failed', + message: message, + }); + } else { + logger.error('Error fetching z.ai usage:', error); + res.status(500).json({ error: message }); + } + } + }); + + // Configure API token (for settings page) + router.post('/configure', async (req: Request, res: Response) => { + try { + const { apiToken, apiHost } = req.body; + + if (apiToken !== undefined) { + // Set in-memory token + usageService.setApiToken(apiToken || ''); + + // Persist to credentials (deep merge happens in updateCredentials) + try { + await settingsService.updateCredentials({ + apiKeys: { zai: apiToken || '' }, + } as Parameters[0]); + logger.info('[configure] Saved z.ai API key to credentials'); + } catch (persistError) { + logger.error('[configure] Failed to persist z.ai API key:', persistError); + } + } + + if (apiHost) { + usageService.setApiHost(apiHost); + } + + res.json({ + success: true, + message: 'z.ai configuration updated', + isAvailable: usageService.isAvailable(), + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + logger.error('Error configuring z.ai:', error); + res.status(500).json({ error: message }); + } + }); + + // Verify API key without storing it (for testing in settings) + router.post('/verify', async (req: Request, res: Response) => { + try { + const { apiKey } = req.body; + + if (!apiKey || typeof apiKey !== 'string' || apiKey.trim().length === 0) { + res.json({ + success: false, + authenticated: false, + error: 'Please provide an API key to test.', + }); + return; + } + + // Test the key by making a request to z.ai API + const quotaUrl = + process.env.Z_AI_QUOTA_URL || + `${process.env.Z_AI_API_HOST ? `https://${process.env.Z_AI_API_HOST}` : 'https://api.z.ai'}/api/monitor/usage/quota/limit`; + + logger.info(`[verify] Testing API key against: ${quotaUrl}`); + + const response = await fetch(quotaUrl, { + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey.trim()}`, + Accept: 'application/json', + }, + }); + + if (response.ok) { + res.json({ + success: true, + authenticated: true, + message: 'Connection successful! z.ai API responded.', + }); + } else if (response.status === 401 || response.status === 403) { + res.json({ + success: false, + authenticated: false, + error: 'Invalid API key. Please check your key and try again.', + }); + } else { + res.json({ + success: false, + authenticated: false, + error: `API request failed: ${response.status} ${response.statusText}`, + }); + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + logger.error('Error verifying z.ai API key:', error); + res.json({ + success: false, + authenticated: false, + error: `Network error: ${message}`, + }); + } + }); + + // Check if z.ai is available + router.get('/status', async (_req: Request, res: Response) => { + try { + const isAvailable = usageService.isAvailable(); + const hasEnvApiKey = Boolean(process.env.Z_AI_API_KEY); + const hasApiKey = usageService.getApiToken() !== null; + + res.json({ + success: true, + available: isAvailable, + hasApiKey, + hasEnvApiKey, + message: isAvailable ? 'z.ai API is configured' : 'z.ai API token not configured', + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + res.status(500).json({ success: false, error: message }); + } + }); + + return router; +} diff --git a/apps/server/src/services/settings-service.ts b/apps/server/src/services/settings-service.ts index 6ffdd488..80e8987f 100644 --- a/apps/server/src/services/settings-service.ts +++ b/apps/server/src/services/settings-service.ts @@ -1018,6 +1018,7 @@ export class SettingsService { anthropic: apiKeys.anthropic || '', google: apiKeys.google || '', openai: apiKeys.openai || '', + zai: '', }, }); migratedCredentials = true; diff --git a/apps/server/src/services/zai-usage-service.ts b/apps/server/src/services/zai-usage-service.ts new file mode 100644 index 00000000..c19cf638 --- /dev/null +++ b/apps/server/src/services/zai-usage-service.ts @@ -0,0 +1,375 @@ +import { createLogger } from '@automaker/utils'; + +const logger = createLogger('ZaiUsage'); + +/** + * z.ai quota limit entry from the API + */ +export interface ZaiQuotaLimit { + limitType: 'TOKENS_LIMIT' | 'TIME_LIMIT' | string; + limit: number; + used: number; + remaining: number; + usedPercent: number; + nextResetTime: number; // epoch milliseconds +} + +/** + * z.ai usage details by model (for MCP tracking) + */ +export interface ZaiUsageDetail { + modelId: string; + used: number; + limit: number; +} + +/** + * z.ai plan types + */ +export type ZaiPlanType = 'free' | 'basic' | 'standard' | 'professional' | 'enterprise' | 'unknown'; + +/** + * z.ai usage data structure + */ +export interface ZaiUsageData { + quotaLimits: { + tokens?: ZaiQuotaLimit; + mcp?: ZaiQuotaLimit; + planType: ZaiPlanType; + } | null; + usageDetails?: ZaiUsageDetail[]; + lastUpdated: string; +} + +/** + * z.ai API limit entry - supports multiple field naming conventions + */ +interface ZaiApiLimit { + // Type field (z.ai uses 'type', others might use 'limitType') + type?: string; + limitType?: string; + // Limit value (z.ai uses 'usage' for total limit, others might use 'limit') + usage?: number; + limit?: number; + // Used value (z.ai uses 'currentValue', others might use 'used') + currentValue?: number; + used?: number; + // Remaining + remaining?: number; + // Percentage (z.ai uses 'percentage', others might use 'usedPercent') + percentage?: number; + usedPercent?: number; + // Reset time + nextResetTime?: number; + // Additional z.ai fields + unit?: number; + number?: number; + usageDetails?: Array<{ modelCode: string; usage: number }>; +} + +/** + * z.ai API response structure + * Flexible to handle various possible response formats + */ +interface ZaiApiResponse { + code?: number; + success?: boolean; + data?: { + limits?: ZaiApiLimit[]; + // Alternative: limits might be an object instead of array + tokensLimit?: { + limit: number; + used: number; + remaining?: number; + usedPercent?: number; + nextResetTime?: number; + }; + timeLimit?: { + limit: number; + used: number; + remaining?: number; + usedPercent?: number; + nextResetTime?: number; + }; + // Quota-style fields + quota?: number; + quotaUsed?: number; + quotaRemaining?: number; + planName?: string; + plan?: string; + plan_type?: string; + packageName?: string; + usageDetails?: Array<{ + modelId: string; + used: number; + limit: number; + }>; + }; + // Root-level alternatives + limits?: ZaiApiLimit[]; + quota?: number; + quotaUsed?: number; + message?: string; +} + +/** + * z.ai Usage Service + * + * Fetches usage quota data from the z.ai API. + * Uses API token authentication stored via environment variable or settings. + */ +export class ZaiUsageService { + private apiToken: string | null = null; + private apiHost: string = 'https://api.z.ai'; + + /** + * Set the API token for authentication + */ + setApiToken(token: string): void { + this.apiToken = token; + logger.info('[setApiToken] API token configured'); + } + + /** + * Get the current API token + */ + getApiToken(): string | null { + // Priority: 1. Instance token, 2. Environment variable + return this.apiToken || process.env.Z_AI_API_KEY || null; + } + + /** + * Set the API host (for BigModel CN region support) + */ + setApiHost(host: string): void { + this.apiHost = host.startsWith('http') ? host : `https://${host}`; + logger.info(`[setApiHost] API host set to: ${this.apiHost}`); + } + + /** + * Get the API host + */ + getApiHost(): string { + // Priority: 1. Instance host, 2. Z_AI_API_HOST env, 3. Default + return process.env.Z_AI_API_HOST ? `https://${process.env.Z_AI_API_HOST}` : this.apiHost; + } + + /** + * Check if z.ai API is available (has token configured) + */ + isAvailable(): boolean { + const token = this.getApiToken(); + return Boolean(token && token.length > 0); + } + + /** + * Fetch usage data from z.ai API + */ + async fetchUsageData(): Promise { + logger.info('[fetchUsageData] Starting...'); + + const token = this.getApiToken(); + if (!token) { + logger.error('[fetchUsageData] No API token configured'); + throw new Error('z.ai API token not configured. Set Z_AI_API_KEY environment variable.'); + } + + const quotaUrl = + process.env.Z_AI_QUOTA_URL || `${this.getApiHost()}/api/monitor/usage/quota/limit`; + + logger.info(`[fetchUsageData] Fetching from: ${quotaUrl}`); + + try { + const response = await fetch(quotaUrl, { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + }, + }); + + if (!response.ok) { + logger.error(`[fetchUsageData] HTTP ${response.status}: ${response.statusText}`); + throw new Error(`z.ai API request failed: ${response.status} ${response.statusText}`); + } + + const data = (await response.json()) as unknown as ZaiApiResponse; + logger.info('[fetchUsageData] Response received:', JSON.stringify(data, null, 2)); + + return this.parseApiResponse(data); + } catch (error) { + if (error instanceof Error && error.message.includes('z.ai API')) { + throw error; + } + logger.error('[fetchUsageData] Failed to fetch:', error); + throw new Error( + `Failed to fetch z.ai usage data: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + + /** + * Parse the z.ai API response into our data structure + * Handles multiple possible response formats from z.ai API + */ + private parseApiResponse(response: ZaiApiResponse): ZaiUsageData { + const result: ZaiUsageData = { + quotaLimits: { + planType: 'unknown', + }, + lastUpdated: new Date().toISOString(), + }; + + logger.info('[parseApiResponse] Raw response:', JSON.stringify(response, null, 2)); + + // Try to find data - could be in response.data or at root level + let data = response.data; + + // Check for root-level limits array + if (!data && response.limits) { + logger.info('[parseApiResponse] Found limits at root level'); + data = { limits: response.limits }; + } + + // Check for root-level quota fields + if (!data && (response.quota !== undefined || response.quotaUsed !== undefined)) { + logger.info('[parseApiResponse] Found quota fields at root level'); + data = { quota: response.quota, quotaUsed: response.quotaUsed }; + } + + if (!data) { + logger.warn('[parseApiResponse] No data found in response'); + return result; + } + + logger.info('[parseApiResponse] Data keys:', Object.keys(data)); + + // Parse plan type from various possible field names + const planName = data.planName || data.plan || data.plan_type || data.packageName; + + if (planName) { + const normalizedPlan = String(planName).toLowerCase(); + if (['free', 'basic', 'standard', 'professional', 'enterprise'].includes(normalizedPlan)) { + result.quotaLimits!.planType = normalizedPlan as ZaiPlanType; + } + logger.info(`[parseApiResponse] Plan type: ${result.quotaLimits!.planType}`); + } + + // Parse quota limits from array format + if (data.limits && Array.isArray(data.limits)) { + logger.info('[parseApiResponse] Parsing limits array with', data.limits.length, 'entries'); + for (const limit of data.limits) { + logger.info('[parseApiResponse] Processing limit:', JSON.stringify(limit)); + + // Handle different field naming conventions from z.ai API: + // - 'usage' is the total limit, 'currentValue' is the used amount + // - OR 'limit' is the total limit, 'used' is the used amount + const limitVal = limit.usage ?? limit.limit ?? 0; + const usedVal = limit.currentValue ?? limit.used ?? 0; + + // Get percentage from 'percentage' or 'usedPercent' field, or calculate it + const apiPercent = limit.percentage ?? limit.usedPercent; + const calculatedPercent = limitVal > 0 ? (usedVal / limitVal) * 100 : 0; + const usedPercent = + apiPercent !== undefined && apiPercent > 0 ? apiPercent : calculatedPercent; + + // Get limit type from 'type' or 'limitType' field + const rawLimitType = limit.type ?? limit.limitType ?? ''; + + const quotaLimit: ZaiQuotaLimit = { + limitType: rawLimitType || 'TOKENS_LIMIT', + limit: limitVal, + used: usedVal, + remaining: limit.remaining ?? limitVal - usedVal, + usedPercent, + nextResetTime: limit.nextResetTime ?? 0, + }; + + // Match various possible limitType values + const limitType = String(rawLimitType).toUpperCase(); + if (limitType.includes('TOKEN') || limitType === 'TOKENS_LIMIT') { + result.quotaLimits!.tokens = quotaLimit; + logger.info( + `[parseApiResponse] Tokens: ${quotaLimit.used}/${quotaLimit.limit} (${quotaLimit.usedPercent.toFixed(1)}%)` + ); + } else if (limitType.includes('TIME') || limitType === 'TIME_LIMIT') { + result.quotaLimits!.mcp = quotaLimit; + logger.info( + `[parseApiResponse] MCP: ${quotaLimit.used}/${quotaLimit.limit} (${quotaLimit.usedPercent.toFixed(1)}%)` + ); + } else { + // If limitType is unknown, use as tokens by default (first one) + if (!result.quotaLimits!.tokens) { + quotaLimit.limitType = 'TOKENS_LIMIT'; + result.quotaLimits!.tokens = quotaLimit; + logger.info(`[parseApiResponse] Unknown limit type '${rawLimitType}', using as tokens`); + } + } + } + } + + // Parse alternative object-style limits + if (data.tokensLimit) { + const t = data.tokensLimit; + const limitVal = t.limit ?? 0; + const usedVal = t.used ?? 0; + const calculatedPercent = limitVal > 0 ? (usedVal / limitVal) * 100 : 0; + result.quotaLimits!.tokens = { + limitType: 'TOKENS_LIMIT', + limit: limitVal, + used: usedVal, + remaining: t.remaining ?? limitVal - usedVal, + usedPercent: + t.usedPercent !== undefined && t.usedPercent > 0 ? t.usedPercent : calculatedPercent, + nextResetTime: t.nextResetTime ?? 0, + }; + logger.info('[parseApiResponse] Parsed tokensLimit object'); + } + + if (data.timeLimit) { + const t = data.timeLimit; + const limitVal = t.limit ?? 0; + const usedVal = t.used ?? 0; + const calculatedPercent = limitVal > 0 ? (usedVal / limitVal) * 100 : 0; + result.quotaLimits!.mcp = { + limitType: 'TIME_LIMIT', + limit: limitVal, + used: usedVal, + remaining: t.remaining ?? limitVal - usedVal, + usedPercent: + t.usedPercent !== undefined && t.usedPercent > 0 ? t.usedPercent : calculatedPercent, + nextResetTime: t.nextResetTime ?? 0, + }; + logger.info('[parseApiResponse] Parsed timeLimit object'); + } + + // Parse simple quota/quotaUsed format as tokens + if (data.quota !== undefined && data.quotaUsed !== undefined && !result.quotaLimits!.tokens) { + const limitVal = Number(data.quota) || 0; + const usedVal = Number(data.quotaUsed) || 0; + result.quotaLimits!.tokens = { + limitType: 'TOKENS_LIMIT', + limit: limitVal, + used: usedVal, + remaining: + data.quotaRemaining !== undefined ? Number(data.quotaRemaining) : limitVal - usedVal, + usedPercent: limitVal > 0 ? (usedVal / limitVal) * 100 : 0, + nextResetTime: 0, + }; + logger.info('[parseApiResponse] Parsed simple quota format'); + } + + // Parse usage details (MCP tracking) + if (data.usageDetails && Array.isArray(data.usageDetails)) { + result.usageDetails = data.usageDetails.map((detail) => ({ + modelId: detail.modelId, + used: detail.used, + limit: detail.limit, + })); + logger.info(`[parseApiResponse] Usage details for ${result.usageDetails.length} models`); + } + + logger.info('[parseApiResponse] Final result:', JSON.stringify(result, null, 2)); + return result; + } +} diff --git a/apps/ui/src/components/ui/provider-icon.tsx b/apps/ui/src/components/ui/provider-icon.tsx index 415872ce..637fd812 100644 --- a/apps/ui/src/components/ui/provider-icon.tsx +++ b/apps/ui/src/components/ui/provider-icon.tsx @@ -105,8 +105,9 @@ const PROVIDER_ICON_DEFINITIONS: Record }, glm: { viewBox: '0 0 24 24', - // Official Z.ai logo from lobehub/lobe-icons (GLM provider) + // Official Z.ai/GLM logo from lobehub/lobe-icons (GLM/Zhipu provider) path: 'M12.105 2L9.927 4.953H.653L2.83 2h9.276zM23.254 19.048L21.078 22h-9.242l2.174-2.952h9.244zM24 2L9.264 22H0L14.736 2H24z', + fill: '#3B82F6', // z.ai brand blue }, bigpickle: { viewBox: '0 0 24 24', @@ -391,12 +392,15 @@ export function GlmIcon({ className, title, ...props }: { className?: string; ti {title && {title}} ); } +// Z.ai icon is the same as GLM (Zhipu AI) +export const ZaiIcon = GlmIcon; + export function BigPickleIcon({ className, title, diff --git a/apps/ui/src/components/usage-popover.tsx b/apps/ui/src/components/usage-popover.tsx index 5d8acb0b..31bb6d5a 100644 --- a/apps/ui/src/components/usage-popover.tsx +++ b/apps/ui/src/components/usage-popover.tsx @@ -6,8 +6,8 @@ import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } f import { Spinner } from '@/components/ui/spinner'; import { cn } from '@/lib/utils'; import { useSetupStore } from '@/store/setup-store'; -import { AnthropicIcon, OpenAIIcon } from '@/components/ui/provider-icon'; -import { useClaudeUsage, useCodexUsage } from '@/hooks/queries'; +import { AnthropicIcon, OpenAIIcon, ZaiIcon } from '@/components/ui/provider-icon'; +import { useClaudeUsage, useCodexUsage, useZaiUsage } from '@/hooks/queries'; // Error codes for distinguishing failure modes const ERROR_CODES = { @@ -27,9 +27,9 @@ type UsageError = { const CLAUDE_SESSION_WINDOW_HOURS = 5; -// Helper to format reset time for Codex -function formatCodexResetTime(unixTimestamp: number): string { - const date = new Date(unixTimestamp * 1000); +// Helper to format reset time for Codex/z.ai (unix timestamp in seconds or milliseconds) +function formatResetTime(unixTimestamp: number, isMilliseconds = false): string { + const date = new Date(isMilliseconds ? unixTimestamp : unixTimestamp * 1000); const now = new Date(); const diff = date.getTime() - now.getTime(); @@ -45,6 +45,11 @@ function formatCodexResetTime(unixTimestamp: number): string { return `Resets ${date.toLocaleDateString()} at ${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`; } +// Legacy alias for Codex +function formatCodexResetTime(unixTimestamp: number): string { + return formatResetTime(unixTimestamp, false); +} + // Helper to format window duration for Codex function getCodexWindowLabel(durationMins: number): { title: string; subtitle: string } { if (durationMins < 60) { @@ -58,16 +63,32 @@ function getCodexWindowLabel(durationMins: number): { title: string; subtitle: s return { title: `${days}d Window`, subtitle: 'Rate limit' }; } +// Helper to format large numbers with K/M suffixes +function formatNumber(num: number): string { + if (num >= 1_000_000_000) { + return `${(num / 1_000_000_000).toFixed(1)}B`; + } + if (num >= 1_000_000) { + return `${(num / 1_000_000).toFixed(1)}M`; + } + if (num >= 1_000) { + return `${(num / 1_000).toFixed(1)}K`; + } + return num.toLocaleString(); +} + export function UsagePopover() { const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus); const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus); + const zaiAuthStatus = useSetupStore((state) => state.zaiAuthStatus); const [open, setOpen] = useState(false); - const [activeTab, setActiveTab] = useState<'claude' | 'codex'>('claude'); + const [activeTab, setActiveTab] = useState<'claude' | 'codex' | 'zai'>('claude'); // Check authentication status const isClaudeAuthenticated = !!claudeAuthStatus?.authenticated; const isCodexAuthenticated = codexAuthStatus?.authenticated; + const isZaiAuthenticated = zaiAuthStatus?.authenticated; // Use React Query hooks for usage data // Only enable polling when popover is open AND the tab is active @@ -87,6 +108,14 @@ export function UsagePopover() { refetch: refetchCodex, } = useCodexUsage(open && activeTab === 'codex' && isCodexAuthenticated); + const { + data: zaiUsage, + isLoading: zaiLoading, + error: zaiQueryError, + dataUpdatedAt: zaiUsageLastUpdated, + refetch: refetchZai, + } = useZaiUsage(open && activeTab === 'zai' && isZaiAuthenticated); + // Parse errors into structured format const claudeError = useMemo((): UsageError | null => { if (!claudeQueryError) return null; @@ -116,14 +145,28 @@ export function UsagePopover() { return { code: ERROR_CODES.AUTH_ERROR, message }; }, [codexQueryError]); + const zaiError = useMemo((): UsageError | null => { + if (!zaiQueryError) return null; + const message = zaiQueryError instanceof Error ? zaiQueryError.message : String(zaiQueryError); + if (message.includes('not configured') || message.includes('API token')) { + return { code: ERROR_CODES.NOT_AVAILABLE, message }; + } + if (message.includes('API bridge')) { + return { code: ERROR_CODES.API_BRIDGE_UNAVAILABLE, message }; + } + return { code: ERROR_CODES.AUTH_ERROR, message }; + }, [zaiQueryError]); + // Determine which tab to show by default useEffect(() => { if (isClaudeAuthenticated) { setActiveTab('claude'); } else if (isCodexAuthenticated) { setActiveTab('codex'); + } else if (isZaiAuthenticated) { + setActiveTab('zai'); } - }, [isClaudeAuthenticated, isCodexAuthenticated]); + }, [isClaudeAuthenticated, isCodexAuthenticated, isZaiAuthenticated]); // Check if data is stale (older than 2 minutes) const isClaudeStale = useMemo(() => { @@ -134,9 +177,14 @@ export function UsagePopover() { return !codexUsageLastUpdated || Date.now() - codexUsageLastUpdated > 2 * 60 * 1000; }, [codexUsageLastUpdated]); + const isZaiStale = useMemo(() => { + return !zaiUsageLastUpdated || Date.now() - zaiUsageLastUpdated > 2 * 60 * 1000; + }, [zaiUsageLastUpdated]); + // Refetch functions for manual refresh const fetchClaudeUsage = () => refetchClaude(); const fetchCodexUsage = () => refetchCodex(); + const fetchZaiUsage = () => refetchZai(); // Derived status color/icon helper const getStatusInfo = (percentage: number) => { @@ -251,26 +299,33 @@ export function UsagePopover() { const indicatorInfo = activeTab === 'claude' ? { - icon: AnthropicIcon, - percentage: claudeSessionPercentage, - isStale: isClaudeStale, - title: `Session usage (${CLAUDE_SESSION_WINDOW_HOURS}h window)`, - } - : { - icon: OpenAIIcon, - percentage: codexWindowUsage ?? 0, - isStale: isCodexStale, - title: `Usage (${codexWindowLabel})`, - }; + icon: AnthropicIcon, + percentage: claudeSessionPercentage, + isStale: isClaudeStale, + title: `Session usage (${CLAUDE_SESSION_WINDOW_HOURS}h window)`, + } + : activeTab === 'codex' ? { + icon: OpenAIIcon, + percentage: codexWindowUsage ?? 0, + isStale: isCodexStale, + title: `Usage (${codexWindowLabel})`, + } : activeTab === 'zai' ? { + icon: ZaiIcon, + percentage: zaiMaxPercentage, + isStale: isZaiStale, + title: `Usage (z.ai)`, + } : null; const statusColor = getStatusInfo(indicatorInfo.percentage).color; const ProviderIcon = indicatorInfo.icon; const trigger = ( + )} + + + {/* Content */} +
+ {zaiError ? ( +
+ +
+

+ {zaiError.code === ERROR_CODES.NOT_AVAILABLE + ? 'z.ai not configured' + : zaiError.message} +

+

+ {zaiError.code === ERROR_CODES.API_BRIDGE_UNAVAILABLE ? ( + 'Ensure the Electron bridge is running or restart the app' + ) : zaiError.code === ERROR_CODES.NOT_AVAILABLE ? ( + <> + Set Z_AI_API_KEY{' '} + environment variable to enable z.ai usage tracking + + ) : ( + <>Check your z.ai API key configuration + )} +

+
+
+ ) : !zaiUsage ? ( +
+ +

Loading usage data...

+
+ ) : zaiUsage.quotaLimits && + (zaiUsage.quotaLimits.tokens || zaiUsage.quotaLimits.mcp) ? ( + <> + {zaiUsage.quotaLimits.tokens && ( + + )} + + {zaiUsage.quotaLimits.mcp && ( + + )} + + {zaiUsage.quotaLimits.planType && zaiUsage.quotaLimits.planType !== 'unknown' && ( +
+

+ Plan:{' '} + + {zaiUsage.quotaLimits.planType.charAt(0).toUpperCase() + + zaiUsage.quotaLimits.planType.slice(1)} + +

+
+ )} + + ) : ( +
+ +

No usage data available

+
+ )} +
+ + {/* Footer */} +
+ + z.ai + + Updates every minute +
+ diff --git a/apps/ui/src/components/views/board-view/board-header.tsx b/apps/ui/src/components/views/board-view/board-header.tsx index 0db3dd48..05303b85 100644 --- a/apps/ui/src/components/views/board-view/board-header.tsx +++ b/apps/ui/src/components/views/board-view/board-header.tsx @@ -81,6 +81,7 @@ export function BoardHeader({ (state) => state.setAddFeatureUseSelectedWorktreeBranch ); const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus); + const zaiAuthStatus = useSetupStore((state) => state.zaiAuthStatus); // Worktree panel visibility (per-project) const worktreePanelVisibleByProject = useAppStore((state) => state.worktreePanelVisibleByProject); @@ -112,6 +113,9 @@ export function BoardHeader({ // Show if Codex is authenticated (CLI or API key) const showCodexUsage = !!codexAuthStatus?.authenticated; + // z.ai usage tracking visibility logic + const showZaiUsage = !!zaiAuthStatus?.authenticated; + // State for mobile actions panel const [showActionsPanel, setShowActionsPanel] = useState(false); const [isRefreshingBoard, setIsRefreshingBoard] = useState(false); @@ -158,8 +162,10 @@ export function BoardHeader({ Refresh board state from server )} - {/* Usage Popover - show if either provider is authenticated, only on desktop */} - {isMounted && !isTablet && (showClaudeUsage || showCodexUsage) && } + {/* Usage Popover - show if any provider is authenticated, only on desktop */} + {isMounted && !isTablet && (showClaudeUsage || showCodexUsage || showZaiUsage) && ( + + )} {/* Tablet/Mobile view: show hamburger menu with all controls */} {isMounted && isTablet && ( @@ -178,6 +184,7 @@ export function BoardHeader({ onOpenPlanDialog={onOpenPlanDialog} showClaudeUsage={showClaudeUsage} showCodexUsage={showCodexUsage} + showZaiUsage={showZaiUsage} /> )} diff --git a/apps/ui/src/components/views/board-view/header-mobile-menu.tsx b/apps/ui/src/components/views/board-view/header-mobile-menu.tsx index f3c2c19d..184e436a 100644 --- a/apps/ui/src/components/views/board-view/header-mobile-menu.tsx +++ b/apps/ui/src/components/views/board-view/header-mobile-menu.tsx @@ -30,6 +30,7 @@ interface HeaderMobileMenuProps { // Usage bar visibility showClaudeUsage: boolean; showCodexUsage: boolean; + showZaiUsage?: boolean; } export function HeaderMobileMenu({ @@ -47,18 +48,23 @@ export function HeaderMobileMenu({ onOpenPlanDialog, showClaudeUsage, showCodexUsage, + showZaiUsage = false, }: HeaderMobileMenuProps) { return ( <> - {/* Usage Bar - show if either provider is authenticated */} - {(showClaudeUsage || showCodexUsage) && ( + {/* Usage Bar - show if any provider is authenticated */} + {(showClaudeUsage || showCodexUsage || showZaiUsage) && (
Usage - +
)} diff --git a/apps/ui/src/components/views/board-view/mobile-usage-bar.tsx b/apps/ui/src/components/views/board-view/mobile-usage-bar.tsx index 918988e9..28225b50 100644 --- a/apps/ui/src/components/views/board-view/mobile-usage-bar.tsx +++ b/apps/ui/src/components/views/board-view/mobile-usage-bar.tsx @@ -4,11 +4,12 @@ import { cn } from '@/lib/utils'; import { Spinner } from '@/components/ui/spinner'; import { getElectronAPI } from '@/lib/electron'; import { useAppStore } from '@/store/app-store'; -import { AnthropicIcon, OpenAIIcon } from '@/components/ui/provider-icon'; +import { AnthropicIcon, OpenAIIcon, ZaiIcon } from '@/components/ui/provider-icon'; interface MobileUsageBarProps { showClaudeUsage: boolean; showCodexUsage: boolean; + showZaiUsage?: boolean; } // Helper to get progress bar color based on percentage @@ -18,15 +19,51 @@ function getProgressBarColor(percentage: number): string { return 'bg-green-500'; } +// Helper to format large numbers with K/M suffixes +function formatNumber(num: number): string { + if (num >= 1_000_000_000) { + return `${(num / 1_000_000_000).toFixed(1)}B`; + } + if (num >= 1_000_000) { + return `${(num / 1_000_000).toFixed(1)}M`; + } + if (num >= 1_000) { + return `${(num / 1_000).toFixed(1)}K`; + } + return num.toLocaleString(); +} + +// Helper to format reset time +function formatResetTime(unixTimestamp: number, isMilliseconds = false): string { + const date = new Date(isMilliseconds ? unixTimestamp : unixTimestamp * 1000); + const now = new Date(); + const diff = date.getTime() - now.getTime(); + + if (diff < 3600000) { + const mins = Math.ceil(diff / 60000); + return `Resets in ${mins}m`; + } + if (diff < 86400000) { + const hours = Math.floor(diff / 3600000); + const mins = Math.ceil((diff % 3600000) / 60000); + return `Resets in ${hours}h${mins > 0 ? ` ${mins}m` : ''}`; + } + return `Resets ${date.toLocaleDateString()}`; +} + // Individual usage bar component function UsageBar({ label, percentage, isStale, + details, + resetText, }: { label: string; percentage: number; isStale: boolean; + details?: string; + resetText?: string; }) { return (
@@ -58,6 +95,14 @@ function UsageBar({ style={{ width: `${Math.min(percentage, 100)}%` }} />
+ {(details || resetText) && ( +
+ {details && {details}} + {resetText && ( + {resetText} + )} +
+ )} ); } @@ -103,16 +148,23 @@ function UsageItem({ ); } -export function MobileUsageBar({ showClaudeUsage, showCodexUsage }: MobileUsageBarProps) { +export function MobileUsageBar({ + showClaudeUsage, + showCodexUsage, + showZaiUsage = false, +}: MobileUsageBarProps) { const { claudeUsage, claudeUsageLastUpdated, setClaudeUsage } = useAppStore(); const { codexUsage, codexUsageLastUpdated, setCodexUsage } = useAppStore(); + const { zaiUsage, zaiUsageLastUpdated, setZaiUsage } = useAppStore(); const [isClaudeLoading, setIsClaudeLoading] = useState(false); const [isCodexLoading, setIsCodexLoading] = useState(false); + const [isZaiLoading, setIsZaiLoading] = useState(false); // Check if data is stale (older than 2 minutes) const isClaudeStale = !claudeUsageLastUpdated || Date.now() - claudeUsageLastUpdated > 2 * 60 * 1000; const isCodexStale = !codexUsageLastUpdated || Date.now() - codexUsageLastUpdated > 2 * 60 * 1000; + const isZaiStale = !zaiUsageLastUpdated || Date.now() - zaiUsageLastUpdated > 2 * 60 * 1000; const fetchClaudeUsage = useCallback(async () => { setIsClaudeLoading(true); @@ -146,6 +198,22 @@ export function MobileUsageBar({ showClaudeUsage, showCodexUsage }: MobileUsageB } }, [setCodexUsage]); + const fetchZaiUsage = useCallback(async () => { + setIsZaiLoading(true); + try { + const api = getElectronAPI(); + if (!api.zai) return; + const data = await api.zai.getUsage(); + if (!('error' in data)) { + setZaiUsage(data); + } + } catch { + // Silently fail - usage display is optional + } finally { + setIsZaiLoading(false); + } + }, [setZaiUsage]); + const getCodexWindowLabel = (durationMins: number) => { if (durationMins < 60) return `${durationMins}m Window`; if (durationMins < 1440) return `${Math.round(durationMins / 60)}h Window`; @@ -165,8 +233,14 @@ export function MobileUsageBar({ showClaudeUsage, showCodexUsage }: MobileUsageB } }, [showCodexUsage, isCodexStale, fetchCodexUsage]); + useEffect(() => { + if (showZaiUsage && isZaiStale) { + fetchZaiUsage(); + } + }, [showZaiUsage, isZaiStale, fetchZaiUsage]); + // Don't render if there's nothing to show - if (!showClaudeUsage && !showCodexUsage) { + if (!showClaudeUsage && !showCodexUsage && !showZaiUsage) { return null; } @@ -227,6 +301,45 @@ export function MobileUsageBar({ showClaudeUsage, showCodexUsage }: MobileUsageB )} )} + + {showZaiUsage && ( + + {zaiUsage?.quotaLimits && (zaiUsage.quotaLimits.tokens || zaiUsage.quotaLimits.mcp) ? ( + <> + {zaiUsage.quotaLimits.tokens && ( + + )} + {zaiUsage.quotaLimits.mcp && ( + + )} + + ) : zaiUsage ? ( +

No usage data from z.ai API

+ ) : ( +

Loading usage data...

+ )} +
+ )} ); } diff --git a/apps/ui/src/components/views/settings-view/api-keys/hooks/use-api-key-management.ts b/apps/ui/src/components/views/settings-view/api-keys/hooks/use-api-key-management.ts index 0290ec9e..1b6738ec 100644 --- a/apps/ui/src/components/views/settings-view/api-keys/hooks/use-api-key-management.ts +++ b/apps/ui/src/components/views/settings-view/api-keys/hooks/use-api-key-management.ts @@ -1,7 +1,11 @@ // @ts-nocheck - API key management state with validation and persistence import { useState, useEffect } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; import { createLogger } from '@automaker/utils/logger'; import { useAppStore } from '@/store/app-store'; +import { useSetupStore, type ZaiAuthMethod } from '@/store/setup-store'; +import { getHttpApiClient } from '@/lib/http-api-client'; +import { queryKeys } from '@/lib/query-keys'; const logger = createLogger('ApiKeyManagement'); import { getElectronAPI } from '@/lib/electron'; @@ -16,6 +20,7 @@ interface ApiKeyStatus { hasAnthropicKey: boolean; hasGoogleKey: boolean; hasOpenaiKey: boolean; + hasZaiKey: boolean; } /** @@ -24,16 +29,20 @@ interface ApiKeyStatus { */ export function useApiKeyManagement() { const { apiKeys, setApiKeys } = useAppStore(); + const { setZaiAuthStatus } = useSetupStore(); + const queryClient = useQueryClient(); // API key values const [anthropicKey, setAnthropicKey] = useState(apiKeys.anthropic); const [googleKey, setGoogleKey] = useState(apiKeys.google); const [openaiKey, setOpenaiKey] = useState(apiKeys.openai); + const [zaiKey, setZaiKey] = useState(apiKeys.zai); // Visibility toggles const [showAnthropicKey, setShowAnthropicKey] = useState(false); const [showGoogleKey, setShowGoogleKey] = useState(false); const [showOpenaiKey, setShowOpenaiKey] = useState(false); + const [showZaiKey, setShowZaiKey] = useState(false); // Test connection states const [testingConnection, setTestingConnection] = useState(false); @@ -42,6 +51,8 @@ export function useApiKeyManagement() { const [geminiTestResult, setGeminiTestResult] = useState(null); const [testingOpenaiConnection, setTestingOpenaiConnection] = useState(false); const [openaiTestResult, setOpenaiTestResult] = useState(null); + const [testingZaiConnection, setTestingZaiConnection] = useState(false); + const [zaiTestResult, setZaiTestResult] = useState(null); // API key status from environment const [apiKeyStatus, setApiKeyStatus] = useState(null); @@ -54,6 +65,7 @@ export function useApiKeyManagement() { setAnthropicKey(apiKeys.anthropic); setGoogleKey(apiKeys.google); setOpenaiKey(apiKeys.openai); + setZaiKey(apiKeys.zai); }, [apiKeys]); // Check API key status from environment on mount @@ -68,6 +80,7 @@ export function useApiKeyManagement() { hasAnthropicKey: status.hasAnthropicKey, hasGoogleKey: status.hasGoogleKey, hasOpenaiKey: status.hasOpenaiKey, + hasZaiKey: status.hasZaiKey || false, }); } } catch (error) { @@ -173,13 +186,89 @@ export function useApiKeyManagement() { } }; + // Test z.ai connection + const handleTestZaiConnection = async () => { + setTestingZaiConnection(true); + setZaiTestResult(null); + + // Validate input first + if (!zaiKey || zaiKey.trim().length === 0) { + setZaiTestResult({ + success: false, + message: 'Please enter an API key to test.', + }); + setTestingZaiConnection(false); + return; + } + + try { + const api = getElectronAPI(); + // Use the verify endpoint to test the key without storing it + const response = await api.zai?.verify(zaiKey); + + if (response?.success && response?.authenticated) { + setZaiTestResult({ + success: true, + message: response.message || 'Connection successful! z.ai API responded.', + }); + } else { + setZaiTestResult({ + success: false, + message: response?.error || 'Failed to connect to z.ai API.', + }); + } + } catch { + setZaiTestResult({ + success: false, + message: 'Network error. Please check your connection.', + }); + } finally { + setTestingZaiConnection(false); + } + }; + // Save API keys - const handleSave = () => { + const handleSave = async () => { setApiKeys({ anthropic: anthropicKey, google: googleKey, openai: openaiKey, + zai: zaiKey, }); + + // Configure z.ai service on the server with the new key + if (zaiKey && zaiKey.trim().length > 0) { + try { + const api = getHttpApiClient(); + const result = await api.zai.configure(zaiKey.trim()); + + if (result.success || result.isAvailable) { + // Update z.ai auth status in the store + setZaiAuthStatus({ + authenticated: true, + method: 'api_key' as ZaiAuthMethod, + hasApiKey: true, + hasEnvApiKey: false, + }); + // Invalidate the z.ai usage query so it refetches with the new key + await queryClient.invalidateQueries({ queryKey: queryKeys.usage.zai() }); + logger.info('z.ai API key configured successfully'); + } + } catch (error) { + logger.error('Failed to configure z.ai API key:', error); + } + } else { + // Clear z.ai auth status if key is removed + setZaiAuthStatus({ + authenticated: false, + method: 'none' as ZaiAuthMethod, + hasApiKey: false, + hasEnvApiKey: false, + }); + // Invalidate the query to clear any cached data + await queryClient.invalidateQueries({ queryKey: queryKeys.usage.zai() }); + } + setSaved(true); setTimeout(() => setSaved(false), 2000); }; @@ -214,6 +303,15 @@ export function useApiKeyManagement() { onTest: handleTestOpenaiConnection, result: openaiTestResult, }, + zai: { + value: zaiKey, + setValue: setZaiKey, + show: showZaiKey, + setShow: setShowZaiKey, + testing: testingZaiConnection, + onTest: handleTestZaiConnection, + result: zaiTestResult, + }, }; return { diff --git a/apps/ui/src/config/api-providers.ts b/apps/ui/src/config/api-providers.ts index e3cc2a51..140d0c24 100644 --- a/apps/ui/src/config/api-providers.ts +++ b/apps/ui/src/config/api-providers.ts @@ -1,7 +1,7 @@ import type { Dispatch, SetStateAction } from 'react'; import type { ApiKeys } from '@/store/app-store'; -export type ProviderKey = 'anthropic' | 'google' | 'openai'; +export type ProviderKey = 'anthropic' | 'google' | 'openai' | 'zai'; export interface ProviderConfig { key: ProviderKey; @@ -59,12 +59,22 @@ export interface ProviderConfigParams { onTest: () => Promise; result: { success: boolean; message: string } | null; }; + zai: { + value: string; + setValue: Dispatch>; + show: boolean; + setShow: Dispatch>; + testing: boolean; + onTest: () => Promise; + result: { success: boolean; message: string } | null; + }; } export const buildProviderConfigs = ({ apiKeys, anthropic, openai, + zai, }: ProviderConfigParams): ProviderConfig[] => [ { key: 'anthropic', @@ -118,6 +128,32 @@ export const buildProviderConfigs = ({ descriptionLinkText: 'platform.openai.com', descriptionSuffix: '.', }, + { + key: 'zai', + label: 'z.ai API Key', + inputId: 'zai-key', + placeholder: 'Enter your z.ai API key', + value: zai.value, + setValue: zai.setValue, + showValue: zai.show, + setShowValue: zai.setShow, + hasStoredKey: apiKeys.zai, + inputTestId: 'zai-api-key-input', + toggleTestId: 'toggle-zai-visibility', + testButton: { + onClick: zai.onTest, + disabled: !zai.value || zai.testing, + loading: zai.testing, + testId: 'test-zai-connection', + }, + result: zai.result, + resultTestId: 'zai-test-connection-result', + resultMessageTestId: 'zai-test-connection-message', + descriptionPrefix: 'Used for z.ai usage tracking and GLM models. Get your key at', + descriptionLinkHref: 'https://z.ai', + descriptionLinkText: 'z.ai', + descriptionSuffix: '.', + }, // { // key: "google", // label: "Google API Key (Gemini)", diff --git a/apps/ui/src/hooks/queries/index.ts b/apps/ui/src/hooks/queries/index.ts index 8cfdf745..186b5b4e 100644 --- a/apps/ui/src/hooks/queries/index.ts +++ b/apps/ui/src/hooks/queries/index.ts @@ -23,7 +23,7 @@ export { } from './use-github'; // Usage -export { useClaudeUsage, useCodexUsage } from './use-usage'; +export { useClaudeUsage, useCodexUsage, useZaiUsage } from './use-usage'; // Running Agents export { useRunningAgents, useRunningAgentsCount } from './use-running-agents'; diff --git a/apps/ui/src/hooks/queries/use-usage.ts b/apps/ui/src/hooks/queries/use-usage.ts index 523c53f1..c159ac06 100644 --- a/apps/ui/src/hooks/queries/use-usage.ts +++ b/apps/ui/src/hooks/queries/use-usage.ts @@ -1,7 +1,7 @@ /** * Usage Query Hooks * - * React Query hooks for fetching Claude and Codex API usage data. + * React Query hooks for fetching Claude, Codex, and z.ai API usage data. * These hooks include automatic polling for real-time usage updates. */ @@ -9,7 +9,7 @@ import { useQuery } from '@tanstack/react-query'; import { getElectronAPI } from '@/lib/electron'; import { queryKeys } from '@/lib/query-keys'; import { STALE_TIMES } from '@/lib/query-client'; -import type { ClaudeUsage, CodexUsage } from '@/store/app-store'; +import type { ClaudeUsage, CodexUsage, ZaiUsage } from '@/store/app-store'; /** Polling interval for usage data (60 seconds) */ const USAGE_POLLING_INTERVAL = 60 * 1000; @@ -87,3 +87,36 @@ export function useCodexUsage(enabled = true) { refetchOnReconnect: USAGE_REFETCH_ON_RECONNECT, }); } + +/** + * Fetch z.ai API usage data + * + * @param enabled - Whether the query should run (default: true) + * @returns Query result with z.ai usage data + * + * @example + * ```tsx + * const { data: usage, isLoading } = useZaiUsage(isPopoverOpen); + * ``` + */ +export function useZaiUsage(enabled = true) { + return useQuery({ + queryKey: queryKeys.usage.zai(), + queryFn: async (): Promise => { + const api = getElectronAPI(); + const result = await api.zai.getUsage(); + // Check if result is an error response + if ('error' in result) { + throw new Error(result.message || result.error); + } + return result; + }, + enabled, + staleTime: STALE_TIMES.USAGE, + refetchInterval: enabled ? USAGE_POLLING_INTERVAL : false, + // Keep previous data while refetching + placeholderData: (previousData) => previousData, + refetchOnWindowFocus: USAGE_REFETCH_ON_FOCUS, + refetchOnReconnect: USAGE_REFETCH_ON_RECONNECT, + }); +} diff --git a/apps/ui/src/hooks/use-provider-auth-init.ts b/apps/ui/src/hooks/use-provider-auth-init.ts index ae95d121..c784e7bd 100644 --- a/apps/ui/src/hooks/use-provider-auth-init.ts +++ b/apps/ui/src/hooks/use-provider-auth-init.ts @@ -1,18 +1,29 @@ import { useEffect, useRef, useCallback } from 'react'; -import { useSetupStore, type ClaudeAuthMethod, type CodexAuthMethod } from '@/store/setup-store'; +import { + useSetupStore, + type ClaudeAuthMethod, + type CodexAuthMethod, + type ZaiAuthMethod, +} from '@/store/setup-store'; import { getHttpApiClient } from '@/lib/http-api-client'; import { createLogger } from '@automaker/utils/logger'; const logger = createLogger('ProviderAuthInit'); /** - * Hook to initialize Claude and Codex authentication statuses on app startup. + * Hook to initialize Claude, Codex, and z.ai authentication statuses on app startup. * This ensures that usage tracking information is available in the board header * without needing to visit the settings page first. */ export function useProviderAuthInit() { - const { setClaudeAuthStatus, setCodexAuthStatus, claudeAuthStatus, codexAuthStatus } = - useSetupStore(); + const { + setClaudeAuthStatus, + setCodexAuthStatus, + setZaiAuthStatus, + claudeAuthStatus, + codexAuthStatus, + zaiAuthStatus, + } = useSetupStore(); const initialized = useRef(false); const refreshStatuses = useCallback(async () => { @@ -88,15 +99,40 @@ export function useProviderAuthInit() { } catch (error) { logger.error('Failed to init Codex auth status:', error); } - }, [setClaudeAuthStatus, setCodexAuthStatus]); + + // 3. z.ai Auth Status + try { + const result = await api.zai.getStatus(); + if (result.success || result.available !== undefined) { + let method: ZaiAuthMethod = 'none'; + if (result.hasEnvApiKey) { + method = 'api_key_env'; + } else if (result.hasApiKey || result.available) { + method = 'api_key'; + } + + setZaiAuthStatus({ + authenticated: result.available, + method, + hasApiKey: result.hasApiKey ?? result.available, + hasEnvApiKey: result.hasEnvApiKey ?? false, + }); + } + } catch (error) { + logger.error('Failed to init z.ai auth status:', error); + } + }, [setClaudeAuthStatus, setCodexAuthStatus, setZaiAuthStatus]); useEffect(() => { // Only initialize once per session if not already set - if (initialized.current || (claudeAuthStatus !== null && codexAuthStatus !== null)) { + if ( + initialized.current || + (claudeAuthStatus !== null && codexAuthStatus !== null && zaiAuthStatus !== null) + ) { return; } initialized.current = true; void refreshStatuses(); - }, [refreshStatuses, claudeAuthStatus, codexAuthStatus]); + }, [refreshStatuses, claudeAuthStatus, codexAuthStatus, zaiAuthStatus]); } diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index 22079822..14568453 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -1,6 +1,6 @@ // Type definitions for Electron IPC API import type { SessionListItem, Message } from '@/types/electron'; -import type { ClaudeUsageResponse, CodexUsageResponse } from '@/store/app-store'; +import type { ClaudeUsageResponse, CodexUsageResponse, ZaiUsageResponse } from '@/store/app-store'; import type { IssueValidationVerdict, IssueValidationConfidence, @@ -865,6 +865,15 @@ export interface ElectronAPI { error?: string; }>; }; + zai?: { + getUsage: () => Promise; + verify: (apiKey: string) => Promise<{ + success: boolean; + authenticated: boolean; + message?: string; + error?: string; + }>; + }; settings?: { getStatus: () => Promise<{ success: boolean; @@ -1364,6 +1373,51 @@ const _getMockElectronAPI = (): ElectronAPI => { }; }, }, + + // Mock z.ai API + zai: { + getUsage: async () => { + console.log('[Mock] Getting z.ai usage'); + return { + quotaLimits: { + tokens: { + limitType: 'TOKENS_LIMIT', + limit: 1000000, + used: 250000, + remaining: 750000, + usedPercent: 25, + nextResetTime: Date.now() + 86400000, + }, + time: { + limitType: 'TIME_LIMIT', + limit: 3600, + used: 900, + remaining: 2700, + usedPercent: 25, + nextResetTime: Date.now() + 3600000, + }, + planType: 'standard', + }, + lastUpdated: new Date().toISOString(), + }; + }, + verify: async (apiKey: string) => { + console.log('[Mock] Verifying z.ai API key'); + // Mock successful verification if key is provided + if (apiKey && apiKey.trim().length > 0) { + return { + success: true, + authenticated: true, + message: 'Connection successful! z.ai API responded.', + }; + } + return { + success: false, + authenticated: false, + error: 'Please provide an API key to test.', + }; + }, + }, }; }; diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index acd75d22..b65ab872 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -1737,6 +1737,67 @@ export class HttpApiClient implements ElectronAPI { }, }; + // z.ai API + zai = { + getStatus: (): Promise<{ + success: boolean; + available: boolean; + message?: string; + hasApiKey?: boolean; + hasEnvApiKey?: boolean; + error?: string; + }> => this.get('/api/zai/status'), + + getUsage: (): Promise<{ + quotaLimits?: { + tokens?: { + limitType: string; + limit: number; + used: number; + remaining: number; + usedPercent: number; + nextResetTime: number; + }; + time?: { + limitType: string; + limit: number; + used: number; + remaining: number; + usedPercent: number; + nextResetTime: number; + }; + planType: string; + } | null; + usageDetails?: Array<{ + modelId: string; + used: number; + limit: number; + }>; + lastUpdated: string; + error?: string; + message?: string; + }> => this.get('/api/zai/usage'), + + configure: ( + apiToken?: string, + apiHost?: string + ): Promise<{ + success: boolean; + message?: string; + isAvailable?: boolean; + error?: string; + }> => this.post('/api/zai/configure', { apiToken, apiHost }), + + verify: ( + apiKey: string + ): Promise<{ + success: boolean; + authenticated: boolean; + message?: string; + error?: string; + }> => this.post('/api/zai/verify', { apiKey }), + }; + // Features API features: FeaturesAPI & { bulkUpdate: ( diff --git a/apps/ui/src/lib/query-keys.ts b/apps/ui/src/lib/query-keys.ts index afe4b5b0..aad0208d 100644 --- a/apps/ui/src/lib/query-keys.ts +++ b/apps/ui/src/lib/query-keys.ts @@ -99,6 +99,8 @@ export const queryKeys = { claude: () => ['usage', 'claude'] as const, /** Codex API usage */ codex: () => ['usage', 'codex'] as const, + /** z.ai API usage */ + zai: () => ['usage', 'zai'] as const, }, // ============================================ diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index c0735355..4d4868b6 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -94,6 +94,10 @@ import { type CodexRateLimitWindow, type CodexUsage, type CodexUsageResponse, + type ZaiPlanType, + type ZaiQuotaLimit, + type ZaiUsage, + type ZaiUsageResponse, } from './types'; // Import utility functions from modular utils files @@ -173,6 +177,10 @@ export type { CodexRateLimitWindow, CodexUsage, CodexUsageResponse, + ZaiPlanType, + ZaiQuotaLimit, + ZaiUsage, + ZaiUsageResponse, }; // Re-export values from ./types for backward compatibility @@ -234,6 +242,7 @@ const initialState: AppState = { anthropic: '', google: '', openai: '', + zai: '', }, chatSessions: [], currentChatSession: null, @@ -314,6 +323,8 @@ const initialState: AppState = { claudeUsageLastUpdated: null, codexUsage: null, codexUsageLastUpdated: null, + zaiUsage: null, + zaiUsageLastUpdated: null, codexModels: [], codexModelsLoading: false, codexModelsError: null, @@ -2400,6 +2411,9 @@ export const useAppStore = create()((set, get) => ({ // Codex Usage Tracking actions setCodexUsage: (usage) => set({ codexUsage: usage, codexUsageLastUpdated: Date.now() }), + // z.ai Usage Tracking actions + setZaiUsage: (usage) => set({ zaiUsage: usage, zaiUsageLastUpdated: usage ? Date.now() : null }), + // Codex Models actions fetchCodexModels: async (forceRefresh = false) => { const state = get(); diff --git a/apps/ui/src/store/setup-store.ts b/apps/ui/src/store/setup-store.ts index f354e5b1..27a9bdac 100644 --- a/apps/ui/src/store/setup-store.ts +++ b/apps/ui/src/store/setup-store.ts @@ -112,6 +112,21 @@ export interface CodexAuthStatus { error?: string; } +// z.ai Auth Method +export type ZaiAuthMethod = + | 'api_key_env' // Z_AI_API_KEY environment variable + | 'api_key' // Manually stored API key + | 'none'; + +// z.ai Auth Status +export interface ZaiAuthStatus { + authenticated: boolean; + method: ZaiAuthMethod; + hasApiKey?: boolean; + hasEnvApiKey?: boolean; + error?: string; +} + // Claude Auth Method - all possible authentication sources export type ClaudeAuthMethod = | 'oauth_token_env' @@ -189,6 +204,9 @@ export interface SetupState { // Copilot SDK state copilotCliStatus: CopilotCliStatus | null; + // z.ai API state + zaiAuthStatus: ZaiAuthStatus | null; + // Setup preferences skipClaudeSetup: boolean; } @@ -229,6 +247,9 @@ export interface SetupActions { // Copilot SDK setCopilotCliStatus: (status: CopilotCliStatus | null) => void; + // z.ai API + setZaiAuthStatus: (status: ZaiAuthStatus | null) => void; + // Preferences setSkipClaudeSetup: (skip: boolean) => void; } @@ -266,6 +287,8 @@ const initialState: SetupState = { copilotCliStatus: null, + zaiAuthStatus: null, + skipClaudeSetup: shouldSkipSetup, }; @@ -344,6 +367,9 @@ export const useSetupStore = create()((set, get) => ( // Copilot SDK setCopilotCliStatus: (status) => set({ copilotCliStatus: status }), + // z.ai API + setZaiAuthStatus: (status) => set({ zaiAuthStatus: status }), + // Preferences setSkipClaudeSetup: (skip) => set({ skipClaudeSetup: skip }), })); diff --git a/apps/ui/src/store/types/settings-types.ts b/apps/ui/src/store/types/settings-types.ts index 6adb8097..bf371fd0 100644 --- a/apps/ui/src/store/types/settings-types.ts +++ b/apps/ui/src/store/types/settings-types.ts @@ -2,4 +2,5 @@ export interface ApiKeys { anthropic: string; google: string; openai: string; + zai: string; } diff --git a/apps/ui/src/store/types/state-types.ts b/apps/ui/src/store/types/state-types.ts index 4febb1ca..7bf01968 100644 --- a/apps/ui/src/store/types/state-types.ts +++ b/apps/ui/src/store/types/state-types.ts @@ -36,7 +36,7 @@ import type { ApiKeys } from './settings-types'; import type { ChatMessage, ChatSession, FeatureImage } from './chat-types'; import type { TerminalState, TerminalPanelContent, PersistedTerminalState } from './terminal-types'; import type { Feature, ProjectAnalysis } from './project-types'; -import type { ClaudeUsage, CodexUsage } from './usage-types'; +import type { ClaudeUsage, CodexUsage, ZaiUsage } from './usage-types'; /** State for worktree init script execution */ export interface InitScriptState { @@ -297,6 +297,10 @@ export interface AppState { codexUsage: CodexUsage | null; codexUsageLastUpdated: number | null; + // z.ai Usage Tracking + zaiUsage: ZaiUsage | null; + zaiUsageLastUpdated: number | null; + // Codex Models (dynamically fetched) codexModels: Array<{ id: string; @@ -764,6 +768,9 @@ export interface AppActions { // Codex Usage Tracking actions setCodexUsage: (usage: CodexUsage | null) => void; + // z.ai Usage Tracking actions + setZaiUsage: (usage: ZaiUsage | null) => void; + // Codex Models actions fetchCodexModels: (forceRefresh?: boolean) => Promise; setCodexModels: ( diff --git a/apps/ui/src/store/types/usage-types.ts b/apps/ui/src/store/types/usage-types.ts index e097526c..e7c47a5d 100644 --- a/apps/ui/src/store/types/usage-types.ts +++ b/apps/ui/src/store/types/usage-types.ts @@ -58,3 +58,27 @@ export interface CodexUsage { // Response type for Codex usage API (can be success or error) export type CodexUsageResponse = CodexUsage | { error: string; message?: string }; + +// z.ai Usage types +export type ZaiPlanType = 'free' | 'basic' | 'standard' | 'professional' | 'enterprise' | 'unknown'; + +export interface ZaiQuotaLimit { + limitType: 'TOKENS_LIMIT' | 'TIME_LIMIT' | string; + limit: number; + used: number; + remaining: number; + usedPercent: number; // Percentage used (0-100) + nextResetTime: number; // Epoch milliseconds +} + +export interface ZaiUsage { + quotaLimits: { + tokens?: ZaiQuotaLimit; + mcp?: ZaiQuotaLimit; + planType: ZaiPlanType; + } | null; + lastUpdated: string; +} + +// Response type for z.ai usage API (can be success or error) +export type ZaiUsageResponse = ZaiUsage | { error: string; message?: string }; diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index 06743faa..a71cde89 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -1287,6 +1287,8 @@ export interface Credentials { google: string; /** OpenAI API key (for compatibility or alternative providers) */ openai: string; + /** z.ai API key (for GLM models and usage tracking) */ + zai: string; }; } @@ -1615,6 +1617,7 @@ export const DEFAULT_CREDENTIALS: Credentials = { anthropic: '', google: '', openai: '', + zai: '', }, }; From 7d5bc722fa04e4a0efb9a53e8f2731eb34134283 Mon Sep 17 00:00:00 2001 From: eclipxe Date: Sun, 25 Jan 2026 09:44:03 -0800 Subject: [PATCH 077/156] Feat: Show Gemini Usage in usage dropdown and mobile sidebar --- apps/server/src/index.ts | 2 + apps/server/src/routes/gemini/index.ts | 60 ++ .../src/services/gemini-usage-service.ts | 761 ++++++++++++++++++ apps/ui/src/components/usage-popover.tsx | 259 +++++- .../views/board-view/board-header.tsx | 13 +- .../views/board-view/header-mobile-menu.tsx | 5 +- .../views/board-view/mobile-usage-bar.tsx | 87 +- apps/ui/src/hooks/queries/index.ts | 2 +- apps/ui/src/hooks/queries/use-usage.ts | 45 +- apps/ui/src/hooks/use-provider-auth-init.ts | 68 +- apps/ui/src/lib/electron.ts | 24 +- apps/ui/src/lib/http-api-client.ts | 11 +- apps/ui/src/lib/query-keys.ts | 2 + apps/ui/src/store/app-store.ts | 13 +- apps/ui/src/store/setup-store.ts | 20 + apps/ui/src/store/types/usage-types.ts | 52 ++ package-lock.json | 11 - 17 files changed, 1374 insertions(+), 61 deletions(-) create mode 100644 apps/server/src/routes/gemini/index.ts create mode 100644 apps/server/src/services/gemini-usage-service.ts diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index acff315e..c27bac18 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -68,6 +68,7 @@ import { CodexAppServerService } from './services/codex-app-server-service.js'; import { CodexModelCacheService } from './services/codex-model-cache-service.js'; import { createZaiRoutes } from './routes/zai/index.js'; import { ZaiUsageService } from './services/zai-usage-service.js'; +import { createGeminiRoutes } from './routes/gemini/index.js'; import { createGitHubRoutes } from './routes/github/index.js'; import { createContextRoutes } from './routes/context/index.js'; import { createBacklogPlanRoutes } from './routes/backlog-plan/index.js'; @@ -438,6 +439,7 @@ app.use('/api/settings', createSettingsRoutes(settingsService)); app.use('/api/claude', createClaudeRoutes(claudeUsageService)); app.use('/api/codex', createCodexRoutes(codexUsageService, codexModelCacheService)); app.use('/api/zai', createZaiRoutes(zaiUsageService, settingsService)); +app.use('/api/gemini', createGeminiRoutes()); app.use('/api/github', createGitHubRoutes(events, settingsService)); app.use('/api/context', createContextRoutes(settingsService)); app.use('/api/backlog-plan', createBacklogPlanRoutes(events, settingsService)); diff --git a/apps/server/src/routes/gemini/index.ts b/apps/server/src/routes/gemini/index.ts new file mode 100644 index 00000000..c543d827 --- /dev/null +++ b/apps/server/src/routes/gemini/index.ts @@ -0,0 +1,60 @@ +import { Router, Request, Response } from 'express'; +import { GeminiProvider } from '../../providers/gemini-provider.js'; +import { getGeminiUsageService } from '../../services/gemini-usage-service.js'; +import { createLogger } from '@automaker/utils'; + +const logger = createLogger('Gemini'); + +export function createGeminiRoutes(): Router { + const router = Router(); + + // Get current usage/quota data from Google Cloud API + router.get('/usage', async (_req: Request, res: Response) => { + try { + const usageService = getGeminiUsageService(); + const usageData = await usageService.fetchUsageData(); + + res.json(usageData); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + logger.error('Error fetching Gemini usage:', error); + + // Return error in a format the UI expects + res.status(200).json({ + authenticated: false, + authMethod: 'none', + usedPercent: 0, + remainingPercent: 100, + lastUpdated: new Date().toISOString(), + error: `Failed to fetch Gemini usage: ${message}`, + }); + } + }); + + // Check if Gemini is available + router.get('/status', async (_req: Request, res: Response) => { + try { + const provider = new GeminiProvider(); + const status = await provider.detectInstallation(); + + const authMethod = + (status as any).authMethod || + (status.authenticated ? (status.hasApiKey ? 'api_key' : 'cli_login') : 'none'); + + res.json({ + success: true, + installed: status.installed, + version: status.version || null, + path: status.path || null, + authenticated: status.authenticated || false, + authMethod, + hasCredentialsFile: (status as any).hasCredentialsFile || false, + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + res.status(500).json({ success: false, error: message }); + } + }); + + return router; +} diff --git a/apps/server/src/services/gemini-usage-service.ts b/apps/server/src/services/gemini-usage-service.ts new file mode 100644 index 00000000..966d09a4 --- /dev/null +++ b/apps/server/src/services/gemini-usage-service.ts @@ -0,0 +1,761 @@ +/** + * Gemini Usage Service + * + * Service for tracking Gemini CLI usage and quota. + * Uses the internal Google Cloud quota API (same as CodexBar). + * See: https://github.com/steipete/CodexBar/blob/main/docs/gemini.md + * + * OAuth credentials are extracted from the Gemini CLI installation, + * not hardcoded, to ensure compatibility with CLI updates. + */ + +import { createLogger } from '@automaker/utils'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { execSync } from 'child_process'; + +const logger = createLogger('GeminiUsage'); + +// Quota API endpoint (internal Google Cloud API) +const QUOTA_API_URL = 'https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota'; + +// Code Assist endpoint for getting project ID and tier info +const CODE_ASSIST_URL = 'https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist'; + +// Google OAuth endpoints for token refresh +const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token'; + +export interface GeminiQuotaBucket { + /** Model ID this quota applies to */ + modelId: string; + /** Remaining fraction (0-1) */ + remainingFraction: number; + /** ISO-8601 reset time */ + resetTime: string; +} + +/** Simplified quota info for a model tier (Flash or Pro) */ +export interface GeminiTierQuota { + /** Used percentage (0-100) */ + usedPercent: number; + /** Remaining percentage (0-100) */ + remainingPercent: number; + /** Reset time as human-readable string */ + resetText?: string; + /** ISO-8601 reset time */ + resetTime?: string; +} + +export interface GeminiUsageData { + /** Whether authenticated via CLI */ + authenticated: boolean; + /** Authentication method */ + authMethod: 'cli_login' | 'api_key' | 'none'; + /** Usage percentage (100 - remainingFraction * 100) - overall most constrained */ + usedPercent: number; + /** Remaining percentage - overall most constrained */ + remainingPercent: number; + /** Reset time as human-readable string */ + resetText?: string; + /** ISO-8601 reset time */ + resetTime?: string; + /** Model ID with lowest remaining quota */ + constrainedModel?: string; + /** Flash tier quota (aggregated from all flash models) */ + flashQuota?: GeminiTierQuota; + /** Pro tier quota (aggregated from all pro models) */ + proQuota?: GeminiTierQuota; + /** Raw quota buckets for detailed view */ + quotaBuckets?: GeminiQuotaBucket[]; + /** When this data was last fetched */ + lastUpdated: string; + /** Optional error message */ + error?: string; +} + +interface OAuthCredentials { + access_token?: string; + id_token?: string; + refresh_token?: string; + token_type?: string; + expiry_date?: number; + client_id?: string; + client_secret?: string; +} + +interface OAuthClientCredentials { + clientId: string; + clientSecret: string; +} + +interface QuotaResponse { + // The actual API returns 'buckets', not 'quotaBuckets' + buckets?: Array<{ + modelId?: string; + remainingFraction?: number; + resetTime?: string; + tokenType?: string; + }>; + // Legacy field name (in case API changes) + quotaBuckets?: Array<{ + modelId?: string; + remainingFraction?: number; + resetTime?: string; + tokenType?: string; + }>; +} + +/** + * Gemini Usage Service + * + * Provides real usage/quota data for Gemini CLI users. + * Extracts OAuth credentials from the Gemini CLI installation. + */ +export class GeminiUsageService { + private cachedCredentials: OAuthCredentials | null = null; + private cachedClientCredentials: OAuthClientCredentials | null = null; + private credentialsPath: string; + + constructor() { + // Default credentials path for Gemini CLI + this.credentialsPath = path.join(os.homedir(), '.gemini', 'oauth_creds.json'); + } + + /** + * Check if Gemini CLI is authenticated + */ + async isAvailable(): Promise { + const creds = await this.loadCredentials(); + return Boolean(creds?.access_token || creds?.refresh_token); + } + + /** + * Fetch quota/usage data from Google Cloud API + */ + async fetchUsageData(): Promise { + logger.info('[fetchUsageData] Starting...'); + + const creds = await this.loadCredentials(); + + if (!creds || (!creds.access_token && !creds.refresh_token)) { + logger.info('[fetchUsageData] No credentials found'); + return { + authenticated: false, + authMethod: 'none', + usedPercent: 0, + remainingPercent: 100, + lastUpdated: new Date().toISOString(), + error: 'Not authenticated. Run "gemini auth login" to authenticate.', + }; + } + + try { + // Get a valid access token (refresh if needed) + const accessToken = await this.getValidAccessToken(creds); + + if (!accessToken) { + return { + authenticated: false, + authMethod: 'none', + usedPercent: 0, + remainingPercent: 100, + lastUpdated: new Date().toISOString(), + error: 'Failed to obtain access token. Try running "gemini auth login" again.', + }; + } + + // First, get the project ID from loadCodeAssist endpoint + // This is required to get accurate quota data + let projectId: string | undefined; + try { + const codeAssistResponse = await fetch(CODE_ASSIST_URL, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + }); + + if (codeAssistResponse.ok) { + const codeAssistData = (await codeAssistResponse.json()) as { + cloudaicompanionProject?: string; + currentTier?: { id?: string; name?: string }; + }; + projectId = codeAssistData.cloudaicompanionProject; + logger.debug('[fetchUsageData] Got project ID:', projectId); + } + } catch (e) { + logger.debug('[fetchUsageData] Failed to get project ID:', e); + } + + // Fetch quota from Google Cloud API + // Pass project ID to get accurate quota (without it, returns default 100%) + const response = await fetch(QUOTA_API_URL, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(projectId ? { project: projectId } : {}), + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => ''); + logger.error('[fetchUsageData] Quota API error:', response.status, errorText); + + // Still authenticated, but quota API failed + return { + authenticated: true, + authMethod: 'cli_login', + usedPercent: 0, + remainingPercent: 100, + lastUpdated: new Date().toISOString(), + error: `Quota API unavailable (${response.status})`, + }; + } + + const data = (await response.json()) as QuotaResponse; + + // API returns 'buckets', with fallback to 'quotaBuckets' for compatibility + const apiBuckets = data.buckets || data.quotaBuckets; + + logger.debug('[fetchUsageData] Raw buckets:', JSON.stringify(apiBuckets)); + + if (!apiBuckets || apiBuckets.length === 0) { + return { + authenticated: true, + authMethod: 'cli_login', + usedPercent: 0, + remainingPercent: 100, + lastUpdated: new Date().toISOString(), + }; + } + + // Group buckets into Flash and Pro tiers + // Flash: any model with "flash" in the name + // Pro: any model with "pro" in the name + let flashLowestRemaining = 1.0; + let flashResetTime: string | undefined; + let hasFlashModels = false; + let proLowestRemaining = 1.0; + let proResetTime: string | undefined; + let hasProModels = false; + let overallLowestRemaining = 1.0; + let constrainedModel: string | undefined; + let overallResetTime: string | undefined; + + const quotaBuckets: GeminiQuotaBucket[] = apiBuckets.map((bucket) => { + const remaining = bucket.remainingFraction ?? 1.0; + const modelId = bucket.modelId?.toLowerCase() || ''; + + // Track overall lowest + if (remaining < overallLowestRemaining) { + overallLowestRemaining = remaining; + constrainedModel = bucket.modelId; + overallResetTime = bucket.resetTime; + } + + // Group into Flash or Pro tier + if (modelId.includes('flash')) { + hasFlashModels = true; + if (remaining < flashLowestRemaining) { + flashLowestRemaining = remaining; + flashResetTime = bucket.resetTime; + } + // Also track reset time even if at 100% + if (!flashResetTime && bucket.resetTime) { + flashResetTime = bucket.resetTime; + } + } else if (modelId.includes('pro')) { + hasProModels = true; + if (remaining < proLowestRemaining) { + proLowestRemaining = remaining; + proResetTime = bucket.resetTime; + } + // Also track reset time even if at 100% + if (!proResetTime && bucket.resetTime) { + proResetTime = bucket.resetTime; + } + } + + return { + modelId: bucket.modelId || 'unknown', + remainingFraction: remaining, + resetTime: bucket.resetTime || '', + }; + }); + + const usedPercent = Math.round((1 - overallLowestRemaining) * 100); + const remainingPercent = Math.round(overallLowestRemaining * 100); + + // Build tier quotas (only include if we found models for that tier) + const flashQuota: GeminiTierQuota | undefined = hasFlashModels + ? { + usedPercent: Math.round((1 - flashLowestRemaining) * 100), + remainingPercent: Math.round(flashLowestRemaining * 100), + resetText: flashResetTime ? this.formatResetTime(flashResetTime) : undefined, + resetTime: flashResetTime, + } + : undefined; + + const proQuota: GeminiTierQuota | undefined = hasProModels + ? { + usedPercent: Math.round((1 - proLowestRemaining) * 100), + remainingPercent: Math.round(proLowestRemaining * 100), + resetText: proResetTime ? this.formatResetTime(proResetTime) : undefined, + resetTime: proResetTime, + } + : undefined; + + return { + authenticated: true, + authMethod: 'cli_login', + usedPercent, + remainingPercent, + resetText: overallResetTime ? this.formatResetTime(overallResetTime) : undefined, + resetTime: overallResetTime, + constrainedModel, + flashQuota, + proQuota, + quotaBuckets, + lastUpdated: new Date().toISOString(), + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + logger.error('[fetchUsageData] Error:', errorMsg); + + return { + authenticated: true, + authMethod: 'cli_login', + usedPercent: 0, + remainingPercent: 100, + lastUpdated: new Date().toISOString(), + error: `Failed to fetch quota: ${errorMsg}`, + }; + } + } + + /** + * Load OAuth credentials from file + */ + private async loadCredentials(): Promise { + if (this.cachedCredentials) { + return this.cachedCredentials; + } + + // Check multiple possible paths + const possiblePaths = [ + this.credentialsPath, + path.join(os.homedir(), '.gemini', 'oauth_creds.json'), + path.join(os.homedir(), '.config', 'gemini', 'oauth_creds.json'), + ]; + + for (const credPath of possiblePaths) { + try { + if (fs.existsSync(credPath)) { + const content = fs.readFileSync(credPath, 'utf8'); + const creds = JSON.parse(content); + + // Handle different credential formats + if (creds.access_token || creds.refresh_token) { + this.cachedCredentials = creds; + logger.info('[loadCredentials] Loaded from:', credPath); + return creds; + } + + // Some formats nest credentials under 'web' or 'installed' + if (creds.web?.client_id || creds.installed?.client_id) { + const clientCreds = creds.web || creds.installed; + this.cachedCredentials = { + client_id: clientCreds.client_id, + client_secret: clientCreds.client_secret, + }; + return this.cachedCredentials; + } + } + } catch (error) { + logger.debug('[loadCredentials] Failed to load from', credPath, error); + } + } + + return null; + } + + /** + * Find the Gemini CLI binary path + */ + private findGeminiBinaryPath(): string | null { + try { + // Try 'which' on Unix-like systems + const whichResult = execSync('which gemini 2>/dev/null', { encoding: 'utf8' }).trim(); + if (whichResult && fs.existsSync(whichResult)) { + return whichResult; + } + } catch { + // Ignore errors from 'which' + } + + // Check common installation paths + const possiblePaths = [ + // npm global installs + path.join(os.homedir(), '.npm-global', 'bin', 'gemini'), + '/usr/local/bin/gemini', + '/usr/bin/gemini', + // Homebrew + '/opt/homebrew/bin/gemini', + '/usr/local/opt/gemini/bin/gemini', + // nvm/fnm node installs + path.join(os.homedir(), '.nvm', 'versions', 'node'), + path.join(os.homedir(), '.fnm', 'node-versions'), + // Windows + path.join(os.homedir(), 'AppData', 'Roaming', 'npm', 'gemini.cmd'), + path.join(os.homedir(), 'AppData', 'Roaming', 'npm', 'gemini'), + ]; + + for (const p of possiblePaths) { + if (fs.existsSync(p)) { + return p; + } + } + + return null; + } + + /** + * Extract OAuth client credentials from Gemini CLI installation + * This mimics CodexBar's approach of finding oauth2.js in the CLI + */ + private extractOAuthClientCredentials(): OAuthClientCredentials | null { + if (this.cachedClientCredentials) { + return this.cachedClientCredentials; + } + + const geminiBinary = this.findGeminiBinaryPath(); + if (!geminiBinary) { + logger.debug('[extractOAuthClientCredentials] Gemini binary not found'); + return null; + } + + // Resolve symlinks to find actual location + let resolvedPath = geminiBinary; + try { + resolvedPath = fs.realpathSync(geminiBinary); + } catch { + // Use original path if realpath fails + } + + const baseDir = path.dirname(resolvedPath); + logger.debug('[extractOAuthClientCredentials] Base dir:', baseDir); + + // Possible locations for oauth2.js relative to the binary + // Based on CodexBar's search patterns + const possibleOAuth2Paths = [ + // npm global install structure + path.join( + baseDir, + '..', + 'lib', + 'node_modules', + '@google', + 'gemini-cli', + 'dist', + 'src', + 'code_assist', + 'oauth2.js' + ), + path.join( + baseDir, + '..', + 'lib', + 'node_modules', + '@google', + 'gemini-cli-core', + 'dist', + 'src', + 'code_assist', + 'oauth2.js' + ), + // Homebrew/libexec structure + path.join( + baseDir, + '..', + 'libexec', + 'lib', + 'node_modules', + '@google', + 'gemini-cli', + 'dist', + 'src', + 'code_assist', + 'oauth2.js' + ), + path.join( + baseDir, + '..', + 'libexec', + 'lib', + 'node_modules', + '@google', + 'gemini-cli-core', + 'dist', + 'src', + 'code_assist', + 'oauth2.js' + ), + // Direct sibling + path.join(baseDir, '..', 'gemini-cli-core', 'dist', 'src', 'code_assist', 'oauth2.js'), + path.join(baseDir, '..', 'gemini-cli', 'dist', 'src', 'code_assist', 'oauth2.js'), + // Alternative node_modules structures + path.join( + baseDir, + '..', + '..', + 'lib', + 'node_modules', + '@google', + 'gemini-cli', + 'dist', + 'src', + 'code_assist', + 'oauth2.js' + ), + path.join( + baseDir, + '..', + '..', + 'lib', + 'node_modules', + '@google', + 'gemini-cli-core', + 'dist', + 'src', + 'code_assist', + 'oauth2.js' + ), + ]; + + for (const oauth2Path of possibleOAuth2Paths) { + try { + const normalizedPath = path.normalize(oauth2Path); + if (fs.existsSync(normalizedPath)) { + logger.debug('[extractOAuthClientCredentials] Found oauth2.js at:', normalizedPath); + const content = fs.readFileSync(normalizedPath, 'utf8'); + const creds = this.parseOAuthCredentialsFromSource(content); + if (creds) { + this.cachedClientCredentials = creds; + logger.info('[extractOAuthClientCredentials] Extracted credentials from CLI'); + return creds; + } + } + } catch (error) { + logger.debug('[extractOAuthClientCredentials] Failed to read', oauth2Path, error); + } + } + + // Try finding oauth2.js by searching in node_modules + try { + const searchResult = execSync( + `find ${baseDir}/.. -name "oauth2.js" -path "*gemini*" -path "*code_assist*" 2>/dev/null | head -1`, + { encoding: 'utf8', timeout: 5000 } + ).trim(); + + if (searchResult && fs.existsSync(searchResult)) { + logger.debug('[extractOAuthClientCredentials] Found via search:', searchResult); + const content = fs.readFileSync(searchResult, 'utf8'); + const creds = this.parseOAuthCredentialsFromSource(content); + if (creds) { + this.cachedClientCredentials = creds; + logger.info( + '[extractOAuthClientCredentials] Extracted credentials from CLI (via search)' + ); + return creds; + } + } + } catch { + // Ignore search errors + } + + logger.warn('[extractOAuthClientCredentials] Could not extract credentials from CLI'); + return null; + } + + /** + * Parse OAuth client credentials from oauth2.js source code + */ + private parseOAuthCredentialsFromSource(content: string): OAuthClientCredentials | null { + // Patterns based on CodexBar's regex extraction + // Look for: OAUTH_CLIENT_ID = "..." or const clientId = "..." + const clientIdPatterns = [ + /OAUTH_CLIENT_ID\s*=\s*["']([^"']+)["']/, + /clientId\s*[:=]\s*["']([^"']+)["']/, + /client_id\s*[:=]\s*["']([^"']+)["']/, + /"clientId"\s*:\s*["']([^"']+)["']/, + ]; + + const clientSecretPatterns = [ + /OAUTH_CLIENT_SECRET\s*=\s*["']([^"']+)["']/, + /clientSecret\s*[:=]\s*["']([^"']+)["']/, + /client_secret\s*[:=]\s*["']([^"']+)["']/, + /"clientSecret"\s*:\s*["']([^"']+)["']/, + ]; + + let clientId: string | null = null; + let clientSecret: string | null = null; + + for (const pattern of clientIdPatterns) { + const match = content.match(pattern); + if (match && match[1]) { + clientId = match[1]; + break; + } + } + + for (const pattern of clientSecretPatterns) { + const match = content.match(pattern); + if (match && match[1]) { + clientSecret = match[1]; + break; + } + } + + if (clientId && clientSecret) { + logger.debug('[parseOAuthCredentialsFromSource] Found client credentials'); + return { clientId, clientSecret }; + } + + return null; + } + + /** + * Get a valid access token, refreshing if necessary + */ + private async getValidAccessToken(creds: OAuthCredentials): Promise { + // Check if current token is still valid (with 5 min buffer) + if (creds.access_token && creds.expiry_date) { + const now = Date.now(); + if (creds.expiry_date > now + 5 * 60 * 1000) { + logger.debug('[getValidAccessToken] Using existing token (not expired)'); + return creds.access_token; + } + } + + // If we have a refresh token, try to refresh + if (creds.refresh_token) { + // Try to extract credentials from CLI first + const extractedCreds = this.extractOAuthClientCredentials(); + + // Use extracted credentials, then fall back to credentials in file + const clientId = extractedCreds?.clientId || creds.client_id; + const clientSecret = extractedCreds?.clientSecret || creds.client_secret; + + if (!clientId || !clientSecret) { + logger.error('[getValidAccessToken] No client credentials available for token refresh'); + // Return existing token even if expired - it might still work + return creds.access_token || null; + } + + try { + logger.debug('[getValidAccessToken] Refreshing token...'); + const response = await fetch(GOOGLE_TOKEN_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + refresh_token: creds.refresh_token, + grant_type: 'refresh_token', + }), + }); + + if (response.ok) { + const data = (await response.json()) as { access_token?: string; expires_in?: number }; + const newAccessToken = data.access_token; + const expiresIn = data.expires_in || 3600; + + if (newAccessToken) { + logger.info('[getValidAccessToken] Token refreshed successfully'); + + // Update cached credentials + this.cachedCredentials = { + ...creds, + access_token: newAccessToken, + expiry_date: Date.now() + expiresIn * 1000, + }; + + // Save back to file + try { + fs.writeFileSync( + this.credentialsPath, + JSON.stringify(this.cachedCredentials, null, 2) + ); + } catch (e) { + logger.debug('[getValidAccessToken] Could not save refreshed token:', e); + } + + return newAccessToken; + } + } else { + const errorText = await response.text().catch(() => ''); + logger.error('[getValidAccessToken] Token refresh failed:', response.status, errorText); + } + } catch (error) { + logger.error('[getValidAccessToken] Token refresh error:', error); + } + } + + // Return current access token even if it might be expired + return creds.access_token || null; + } + + /** + * Format reset time as human-readable string + */ + private formatResetTime(isoTime: string): string { + try { + const resetDate = new Date(isoTime); + const now = new Date(); + const diff = resetDate.getTime() - now.getTime(); + + if (diff < 0) { + return 'Resetting soon'; + } + + const minutes = Math.floor(diff / 60000); + const hours = Math.floor(minutes / 60); + + if (hours > 0) { + const remainingMins = minutes % 60; + return remainingMins > 0 ? `Resets in ${hours}h ${remainingMins}m` : `Resets in ${hours}h`; + } + + return `Resets in ${minutes}m`; + } catch { + return ''; + } + } + + /** + * Clear cached credentials (useful after logout) + */ + clearCache(): void { + this.cachedCredentials = null; + this.cachedClientCredentials = null; + } +} + +// Singleton instance +let usageServiceInstance: GeminiUsageService | null = null; + +/** + * Get the singleton instance of GeminiUsageService + */ +export function getGeminiUsageService(): GeminiUsageService { + if (!usageServiceInstance) { + usageServiceInstance = new GeminiUsageService(); + } + return usageServiceInstance; +} diff --git a/apps/ui/src/components/usage-popover.tsx b/apps/ui/src/components/usage-popover.tsx index 31bb6d5a..58c6fd27 100644 --- a/apps/ui/src/components/usage-popover.tsx +++ b/apps/ui/src/components/usage-popover.tsx @@ -6,8 +6,8 @@ import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } f import { Spinner } from '@/components/ui/spinner'; import { cn } from '@/lib/utils'; import { useSetupStore } from '@/store/setup-store'; -import { AnthropicIcon, OpenAIIcon, ZaiIcon } from '@/components/ui/provider-icon'; -import { useClaudeUsage, useCodexUsage, useZaiUsage } from '@/hooks/queries'; +import { AnthropicIcon, OpenAIIcon, ZaiIcon, GeminiIcon } from '@/components/ui/provider-icon'; +import { useClaudeUsage, useCodexUsage, useZaiUsage, useGeminiUsage } from '@/hooks/queries'; // Error codes for distinguishing failure modes const ERROR_CODES = { @@ -81,14 +81,16 @@ export function UsagePopover() { const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus); const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus); const zaiAuthStatus = useSetupStore((state) => state.zaiAuthStatus); + const geminiAuthStatus = useSetupStore((state) => state.geminiAuthStatus); const [open, setOpen] = useState(false); - const [activeTab, setActiveTab] = useState<'claude' | 'codex' | 'zai'>('claude'); + const [activeTab, setActiveTab] = useState<'claude' | 'codex' | 'zai' | 'gemini'>('claude'); // Check authentication status const isClaudeAuthenticated = !!claudeAuthStatus?.authenticated; const isCodexAuthenticated = codexAuthStatus?.authenticated; const isZaiAuthenticated = zaiAuthStatus?.authenticated; + const isGeminiAuthenticated = geminiAuthStatus?.authenticated; // Use React Query hooks for usage data // Only enable polling when popover is open AND the tab is active @@ -116,6 +118,14 @@ export function UsagePopover() { refetch: refetchZai, } = useZaiUsage(open && activeTab === 'zai' && isZaiAuthenticated); + const { + data: geminiUsage, + isLoading: geminiLoading, + error: geminiQueryError, + dataUpdatedAt: geminiUsageLastUpdated, + refetch: refetchGemini, + } = useGeminiUsage(open && activeTab === 'gemini' && isGeminiAuthenticated); + // Parse errors into structured format const claudeError = useMemo((): UsageError | null => { if (!claudeQueryError) return null; @@ -157,6 +167,19 @@ export function UsagePopover() { return { code: ERROR_CODES.AUTH_ERROR, message }; }, [zaiQueryError]); + const geminiError = useMemo((): UsageError | null => { + if (!geminiQueryError) return null; + const message = + geminiQueryError instanceof Error ? geminiQueryError.message : String(geminiQueryError); + if (message.includes('not configured') || message.includes('not authenticated')) { + return { code: ERROR_CODES.NOT_AVAILABLE, message }; + } + if (message.includes('API bridge')) { + return { code: ERROR_CODES.API_BRIDGE_UNAVAILABLE, message }; + } + return { code: ERROR_CODES.AUTH_ERROR, message }; + }, [geminiQueryError]); + // Determine which tab to show by default useEffect(() => { if (isClaudeAuthenticated) { @@ -165,8 +188,10 @@ export function UsagePopover() { setActiveTab('codex'); } else if (isZaiAuthenticated) { setActiveTab('zai'); + } else if (isGeminiAuthenticated) { + setActiveTab('gemini'); } - }, [isClaudeAuthenticated, isCodexAuthenticated, isZaiAuthenticated]); + }, [isClaudeAuthenticated, isCodexAuthenticated, isZaiAuthenticated, isGeminiAuthenticated]); // Check if data is stale (older than 2 minutes) const isClaudeStale = useMemo(() => { @@ -181,10 +206,15 @@ export function UsagePopover() { return !zaiUsageLastUpdated || Date.now() - zaiUsageLastUpdated > 2 * 60 * 1000; }, [zaiUsageLastUpdated]); + const isGeminiStale = useMemo(() => { + return !geminiUsageLastUpdated || Date.now() - geminiUsageLastUpdated > 2 * 60 * 1000; + }, [geminiUsageLastUpdated]); + // Refetch functions for manual refresh const fetchClaudeUsage = () => refetchClaude(); const fetchCodexUsage = () => refetchCodex(); const fetchZaiUsage = () => refetchZai(); + const fetchGeminiUsage = () => refetchGemini(); // Derived status color/icon helper const getStatusInfo = (percentage: number) => { @@ -275,6 +305,23 @@ export function UsagePopover() { // Calculate max percentage for header button const claudeSessionPercentage = claudeUsage?.sessionPercentage || 0; + const codexMaxPercentage = codexUsage?.rateLimits + ? Math.max( + codexUsage.rateLimits.primary?.usedPercent || 0, + codexUsage.rateLimits.secondary?.usedPercent || 0 + ) + : 0; + + const zaiMaxPercentage = zaiUsage?.quotaLimits + ? Math.max( + zaiUsage.quotaLimits.tokens?.usedPercent || 0, + zaiUsage.quotaLimits.mcp?.usedPercent || 0 + ) + : 0; + + // Gemini quota from Google Cloud API (if available) + const geminiMaxPercentage = geminiUsage?.usedPercent ?? (geminiUsage?.authenticated ? 0 : 100); + const getProgressBarColor = (percentage: number) => { if (percentage >= 80) return 'bg-red-500'; if (percentage >= 50) return 'bg-yellow-500'; @@ -299,33 +346,43 @@ export function UsagePopover() { const indicatorInfo = activeTab === 'claude' ? { - icon: AnthropicIcon, - percentage: claudeSessionPercentage, - isStale: isClaudeStale, - title: `Session usage (${CLAUDE_SESSION_WINDOW_HOURS}h window)`, - } - : activeTab === 'codex' ? { - icon: OpenAIIcon, - percentage: codexWindowUsage ?? 0, - isStale: isCodexStale, - title: `Usage (${codexWindowLabel})`, - } : activeTab === 'zai' ? { - icon: ZaiIcon, - percentage: zaiMaxPercentage, - isStale: isZaiStale, - title: `Usage (z.ai)`, - } : null; + icon: AnthropicIcon, + percentage: claudeSessionPercentage, + isStale: isClaudeStale, + title: `Session usage (${CLAUDE_SESSION_WINDOW_HOURS}h window)`, + } + : activeTab === 'codex' + ? { + icon: OpenAIIcon, + percentage: codexWindowUsage ?? 0, + isStale: isCodexStale, + } + : activeTab === 'zai' + ? { + icon: ZaiIcon, + percentage: zaiMaxPercentage, + isStale: isZaiStale, + title: `Usage (z.ai)`, + } + : activeTab === 'gemini' + ? { + icon: GeminiIcon, + percentage: geminiMaxPercentage, + isStale: isGeminiStale, + title: `Usage (Gemini)`, + } + : null; const statusColor = getStatusInfo(indicatorInfo.percentage).color; const ProviderIcon = indicatorInfo.icon; const trigger = ( + )} + + + {/* Content */} +
+ {geminiError ? ( +
+ +
+

+ {geminiError.code === ERROR_CODES.NOT_AVAILABLE + ? 'Gemini not configured' + : geminiError.message} +

+

+ {geminiError.code === ERROR_CODES.API_BRIDGE_UNAVAILABLE ? ( + 'Ensure the Electron bridge is running or restart the app' + ) : geminiError.code === ERROR_CODES.NOT_AVAILABLE ? ( + <> + Run{' '} + gemini auth login{' '} + to authenticate with your Google account + + ) : ( + <>Check your Gemini CLI configuration + )} +

+
+
+ ) : !geminiUsage ? ( +
+ +

Loading usage data...

+
+ ) : geminiUsage.authenticated ? ( + <> + {/* Show Flash and Pro quota tiers */} + {geminiUsage.flashQuota || geminiUsage.proQuota ? ( +
+ {geminiUsage.flashQuota && ( + + )} + {geminiUsage.proQuota && ( + + )} +
+ ) : ( + <> + {/* No quota data available - show connected status */} +
+
+ +
+
+

Connected

+

+ Authenticated via{' '} + + {geminiUsage.authMethod === 'cli_login' + ? 'CLI Login' + : geminiUsage.authMethod === 'api_key_env' + ? 'API Key (Environment)' + : geminiUsage.authMethod === 'api_key' + ? 'API Key' + : 'Unknown'} + +

+
+
+ +
+

+ {geminiUsage.error ? ( + <>Quota API: {geminiUsage.error} + ) : ( + <>No usage yet or quota data unavailable + )} +

+
+ + )} + + ) : ( +
+ +

Not authenticated

+

+ Run gemini auth login{' '} + to authenticate +

+
+ )} +
+ + {/* Footer */} +
+ + Google AI + + Updates every minute +
+ diff --git a/apps/ui/src/components/views/board-view/board-header.tsx b/apps/ui/src/components/views/board-view/board-header.tsx index 05303b85..8e3654e3 100644 --- a/apps/ui/src/components/views/board-view/board-header.tsx +++ b/apps/ui/src/components/views/board-view/board-header.tsx @@ -82,6 +82,7 @@ export function BoardHeader({ ); const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus); const zaiAuthStatus = useSetupStore((state) => state.zaiAuthStatus); + const geminiAuthStatus = useSetupStore((state) => state.geminiAuthStatus); // Worktree panel visibility (per-project) const worktreePanelVisibleByProject = useAppStore((state) => state.worktreePanelVisibleByProject); @@ -116,6 +117,9 @@ export function BoardHeader({ // z.ai usage tracking visibility logic const showZaiUsage = !!zaiAuthStatus?.authenticated; + // Gemini usage tracking visibility logic + const showGeminiUsage = !!geminiAuthStatus?.authenticated; + // State for mobile actions panel const [showActionsPanel, setShowActionsPanel] = useState(false); const [isRefreshingBoard, setIsRefreshingBoard] = useState(false); @@ -163,9 +167,11 @@ export function BoardHeader({ )} {/* Usage Popover - show if any provider is authenticated, only on desktop */} - {isMounted && !isTablet && (showClaudeUsage || showCodexUsage || showZaiUsage) && ( - - )} + {isMounted && + !isTablet && + (showClaudeUsage || showCodexUsage || showZaiUsage || showGeminiUsage) && ( + + )} {/* Tablet/Mobile view: show hamburger menu with all controls */} {isMounted && isTablet && ( @@ -185,6 +191,7 @@ export function BoardHeader({ showClaudeUsage={showClaudeUsage} showCodexUsage={showCodexUsage} showZaiUsage={showZaiUsage} + showGeminiUsage={showGeminiUsage} /> )} diff --git a/apps/ui/src/components/views/board-view/header-mobile-menu.tsx b/apps/ui/src/components/views/board-view/header-mobile-menu.tsx index 184e436a..3eed7c0e 100644 --- a/apps/ui/src/components/views/board-view/header-mobile-menu.tsx +++ b/apps/ui/src/components/views/board-view/header-mobile-menu.tsx @@ -31,6 +31,7 @@ interface HeaderMobileMenuProps { showClaudeUsage: boolean; showCodexUsage: boolean; showZaiUsage?: boolean; + showGeminiUsage?: boolean; } export function HeaderMobileMenu({ @@ -49,13 +50,14 @@ export function HeaderMobileMenu({ showClaudeUsage, showCodexUsage, showZaiUsage = false, + showGeminiUsage = false, }: HeaderMobileMenuProps) { return ( <> {/* Usage Bar - show if any provider is authenticated */} - {(showClaudeUsage || showCodexUsage || showZaiUsage) && ( + {(showClaudeUsage || showCodexUsage || showZaiUsage || showGeminiUsage) && (
Usage @@ -64,6 +66,7 @@ export function HeaderMobileMenu({ showClaudeUsage={showClaudeUsage} showCodexUsage={showCodexUsage} showZaiUsage={showZaiUsage} + showGeminiUsage={showGeminiUsage} />
)} diff --git a/apps/ui/src/components/views/board-view/mobile-usage-bar.tsx b/apps/ui/src/components/views/board-view/mobile-usage-bar.tsx index 28225b50..4755dfbb 100644 --- a/apps/ui/src/components/views/board-view/mobile-usage-bar.tsx +++ b/apps/ui/src/components/views/board-view/mobile-usage-bar.tsx @@ -4,12 +4,14 @@ import { cn } from '@/lib/utils'; import { Spinner } from '@/components/ui/spinner'; import { getElectronAPI } from '@/lib/electron'; import { useAppStore } from '@/store/app-store'; -import { AnthropicIcon, OpenAIIcon, ZaiIcon } from '@/components/ui/provider-icon'; +import { AnthropicIcon, OpenAIIcon, ZaiIcon, GeminiIcon } from '@/components/ui/provider-icon'; +import type { GeminiUsage } from '@/store/app-store'; interface MobileUsageBarProps { showClaudeUsage: boolean; showCodexUsage: boolean; showZaiUsage?: boolean; + showGeminiUsage?: boolean; } // Helper to get progress bar color based on percentage @@ -152,6 +154,7 @@ export function MobileUsageBar({ showClaudeUsage, showCodexUsage, showZaiUsage = false, + showGeminiUsage = false, }: MobileUsageBarProps) { const { claudeUsage, claudeUsageLastUpdated, setClaudeUsage } = useAppStore(); const { codexUsage, codexUsageLastUpdated, setCodexUsage } = useAppStore(); @@ -159,12 +162,17 @@ export function MobileUsageBar({ const [isClaudeLoading, setIsClaudeLoading] = useState(false); const [isCodexLoading, setIsCodexLoading] = useState(false); const [isZaiLoading, setIsZaiLoading] = useState(false); + const [isGeminiLoading, setIsGeminiLoading] = useState(false); + const [geminiUsage, setGeminiUsage] = useState(null); + const [geminiUsageLastUpdated, setGeminiUsageLastUpdated] = useState(null); // Check if data is stale (older than 2 minutes) const isClaudeStale = !claudeUsageLastUpdated || Date.now() - claudeUsageLastUpdated > 2 * 60 * 1000; const isCodexStale = !codexUsageLastUpdated || Date.now() - codexUsageLastUpdated > 2 * 60 * 1000; const isZaiStale = !zaiUsageLastUpdated || Date.now() - zaiUsageLastUpdated > 2 * 60 * 1000; + const isGeminiStale = + !geminiUsageLastUpdated || Date.now() - geminiUsageLastUpdated > 2 * 60 * 1000; const fetchClaudeUsage = useCallback(async () => { setIsClaudeLoading(true); @@ -214,6 +222,23 @@ export function MobileUsageBar({ } }, [setZaiUsage]); + const fetchGeminiUsage = useCallback(async () => { + setIsGeminiLoading(true); + try { + const api = getElectronAPI(); + if (!api.gemini) return; + const data = await api.gemini.getUsage(); + if (!('error' in data)) { + setGeminiUsage(data); + setGeminiUsageLastUpdated(Date.now()); + } + } catch { + // Silently fail - usage display is optional + } finally { + setIsGeminiLoading(false); + } + }, []); + const getCodexWindowLabel = (durationMins: number) => { if (durationMins < 60) return `${durationMins}m Window`; if (durationMins < 1440) return `${Math.round(durationMins / 60)}h Window`; @@ -239,8 +264,14 @@ export function MobileUsageBar({ } }, [showZaiUsage, isZaiStale, fetchZaiUsage]); + useEffect(() => { + if (showGeminiUsage && isGeminiStale) { + fetchGeminiUsage(); + } + }, [showGeminiUsage, isGeminiStale, fetchGeminiUsage]); + // Don't render if there's nothing to show - if (!showClaudeUsage && !showCodexUsage && !showZaiUsage) { + if (!showClaudeUsage && !showCodexUsage && !showZaiUsage && !showGeminiUsage) { return null; } @@ -340,6 +371,58 @@ export function MobileUsageBar({ )} )} + + {showGeminiUsage && ( + + {geminiUsage ? ( + geminiUsage.authenticated ? ( + geminiUsage.flashQuota || geminiUsage.proQuota ? ( + <> + {geminiUsage.flashQuota && ( + + )} + {geminiUsage.proQuota && ( + + )} + + ) : ( +
+

+ Connected via{' '} + {geminiUsage.authMethod === 'cli_login' + ? 'CLI Login' + : geminiUsage.authMethod === 'api_key' + ? 'API Key' + : geminiUsage.authMethod} +

+

+ {geminiUsage.error || 'No usage yet'} +

+
+ ) + ) : ( +

Not authenticated

+ ) + ) : ( +

Loading usage data...

+ )} +
+ )} ); } diff --git a/apps/ui/src/hooks/queries/index.ts b/apps/ui/src/hooks/queries/index.ts index 186b5b4e..5a5730ac 100644 --- a/apps/ui/src/hooks/queries/index.ts +++ b/apps/ui/src/hooks/queries/index.ts @@ -23,7 +23,7 @@ export { } from './use-github'; // Usage -export { useClaudeUsage, useCodexUsage, useZaiUsage } from './use-usage'; +export { useClaudeUsage, useCodexUsage, useZaiUsage, useGeminiUsage } from './use-usage'; // Running Agents export { useRunningAgents, useRunningAgentsCount } from './use-running-agents'; diff --git a/apps/ui/src/hooks/queries/use-usage.ts b/apps/ui/src/hooks/queries/use-usage.ts index c159ac06..18fedfa7 100644 --- a/apps/ui/src/hooks/queries/use-usage.ts +++ b/apps/ui/src/hooks/queries/use-usage.ts @@ -1,7 +1,7 @@ /** * Usage Query Hooks * - * React Query hooks for fetching Claude, Codex, and z.ai API usage data. + * React Query hooks for fetching Claude, Codex, z.ai, and Gemini API usage data. * These hooks include automatic polling for real-time usage updates. */ @@ -9,7 +9,7 @@ import { useQuery } from '@tanstack/react-query'; import { getElectronAPI } from '@/lib/electron'; import { queryKeys } from '@/lib/query-keys'; import { STALE_TIMES } from '@/lib/query-client'; -import type { ClaudeUsage, CodexUsage, ZaiUsage } from '@/store/app-store'; +import type { ClaudeUsage, CodexUsage, ZaiUsage, GeminiUsage } from '@/store/app-store'; /** Polling interval for usage data (60 seconds) */ const USAGE_POLLING_INTERVAL = 60 * 1000; @@ -33,7 +33,7 @@ export function useClaudeUsage(enabled = true) { queryFn: async (): Promise => { const api = getElectronAPI(); if (!api.claude) { - throw new Error('Claude API not available'); + throw new Error('Claude API bridge unavailable'); } const result = await api.claude.getUsage(); // Check if result is an error response @@ -69,7 +69,7 @@ export function useCodexUsage(enabled = true) { queryFn: async (): Promise => { const api = getElectronAPI(); if (!api.codex) { - throw new Error('Codex API not available'); + throw new Error('Codex API bridge unavailable'); } const result = await api.codex.getUsage(); // Check if result is an error response @@ -104,6 +104,9 @@ export function useZaiUsage(enabled = true) { queryKey: queryKeys.usage.zai(), queryFn: async (): Promise => { const api = getElectronAPI(); + if (!api.zai) { + throw new Error('z.ai API bridge unavailable'); + } const result = await api.zai.getUsage(); // Check if result is an error response if ('error' in result) { @@ -120,3 +123,37 @@ export function useZaiUsage(enabled = true) { refetchOnReconnect: USAGE_REFETCH_ON_RECONNECT, }); } + +/** + * Fetch Gemini API usage/status data + * + * @param enabled - Whether the query should run (default: true) + * @returns Query result with Gemini usage data + * + * @example + * ```tsx + * const { data: usage, isLoading } = useGeminiUsage(isPopoverOpen); + * ``` + */ +export function useGeminiUsage(enabled = true) { + return useQuery({ + queryKey: queryKeys.usage.gemini(), + queryFn: async (): Promise => { + const api = getElectronAPI(); + if (!api.gemini) { + throw new Error('Gemini API bridge unavailable'); + } + const result = await api.gemini.getUsage(); + // Server always returns a response with 'authenticated' field, even on error + // So we can safely cast to GeminiUsage + return result as GeminiUsage; + }, + enabled, + staleTime: STALE_TIMES.USAGE, + refetchInterval: enabled ? USAGE_POLLING_INTERVAL : false, + // Keep previous data while refetching + placeholderData: (previousData) => previousData, + refetchOnWindowFocus: USAGE_REFETCH_ON_FOCUS, + refetchOnReconnect: USAGE_REFETCH_ON_RECONNECT, + }); +} diff --git a/apps/ui/src/hooks/use-provider-auth-init.ts b/apps/ui/src/hooks/use-provider-auth-init.ts index c784e7bd..f8919b1e 100644 --- a/apps/ui/src/hooks/use-provider-auth-init.ts +++ b/apps/ui/src/hooks/use-provider-auth-init.ts @@ -4,6 +4,7 @@ import { type ClaudeAuthMethod, type CodexAuthMethod, type ZaiAuthMethod, + type GeminiAuthMethod, } from '@/store/setup-store'; import { getHttpApiClient } from '@/lib/http-api-client'; import { createLogger } from '@automaker/utils/logger'; @@ -11,7 +12,7 @@ import { createLogger } from '@automaker/utils/logger'; const logger = createLogger('ProviderAuthInit'); /** - * Hook to initialize Claude, Codex, and z.ai authentication statuses on app startup. + * Hook to initialize Claude, Codex, z.ai, and Gemini authentication statuses on app startup. * This ensures that usage tracking information is available in the board header * without needing to visit the settings page first. */ @@ -20,9 +21,12 @@ export function useProviderAuthInit() { setClaudeAuthStatus, setCodexAuthStatus, setZaiAuthStatus, + setGeminiCliStatus, + setGeminiAuthStatus, claudeAuthStatus, codexAuthStatus, zaiAuthStatus, + geminiAuthStatus, } = useSetupStore(); const initialized = useRef(false); @@ -121,18 +125,74 @@ export function useProviderAuthInit() { } catch (error) { logger.error('Failed to init z.ai auth status:', error); } - }, [setClaudeAuthStatus, setCodexAuthStatus, setZaiAuthStatus]); + + // 4. Gemini Auth Status + try { + const result = await api.setup.getGeminiStatus(); + if (result.success) { + // Set CLI status + setGeminiCliStatus({ + installed: result.installed ?? false, + version: result.version, + path: result.status, + }); + + // Set Auth status - always set a status to mark initialization as complete + if (result.auth) { + const auth = result.auth; + const validMethods: GeminiAuthMethod[] = ['cli_login', 'api_key_env', 'api_key', 'none']; + + const method = validMethods.includes(auth.method as GeminiAuthMethod) + ? (auth.method as GeminiAuthMethod) + : ((auth.authenticated ? 'cli_login' : 'none') as GeminiAuthMethod); + + setGeminiAuthStatus({ + authenticated: auth.authenticated, + method, + hasApiKey: auth.hasApiKey ?? false, + hasEnvApiKey: auth.hasEnvApiKey ?? false, + }); + } else { + // No auth info available, set default unauthenticated status + setGeminiAuthStatus({ + authenticated: false, + method: 'none', + hasApiKey: false, + hasEnvApiKey: false, + }); + } + } + } catch (error) { + logger.error('Failed to init Gemini auth status:', error); + // Set default status on error to prevent infinite retries + setGeminiAuthStatus({ + authenticated: false, + method: 'none', + hasApiKey: false, + hasEnvApiKey: false, + }); + } + }, [ + setClaudeAuthStatus, + setCodexAuthStatus, + setZaiAuthStatus, + setGeminiCliStatus, + setGeminiAuthStatus, + ]); useEffect(() => { // Only initialize once per session if not already set if ( initialized.current || - (claudeAuthStatus !== null && codexAuthStatus !== null && zaiAuthStatus !== null) + (claudeAuthStatus !== null && + codexAuthStatus !== null && + zaiAuthStatus !== null && + geminiAuthStatus !== null) ) { return; } initialized.current = true; void refreshStatuses(); - }, [refreshStatuses, claudeAuthStatus, codexAuthStatus, zaiAuthStatus]); + }, [refreshStatuses, claudeAuthStatus, codexAuthStatus, zaiAuthStatus, geminiAuthStatus]); } diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index 14568453..54ea0c45 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -1,6 +1,11 @@ // Type definitions for Electron IPC API import type { SessionListItem, Message } from '@/types/electron'; -import type { ClaudeUsageResponse, CodexUsageResponse, ZaiUsageResponse } from '@/store/app-store'; +import type { + ClaudeUsageResponse, + CodexUsageResponse, + ZaiUsageResponse, + GeminiUsageResponse, +} from '@/store/app-store'; import type { IssueValidationVerdict, IssueValidationConfidence, @@ -874,6 +879,9 @@ export interface ElectronAPI { error?: string; }>; }; + gemini?: { + getUsage: () => Promise; + }; settings?: { getStatus: () => Promise<{ success: boolean; @@ -1418,6 +1426,20 @@ const _getMockElectronAPI = (): ElectronAPI => { }; }, }, + + // Mock Gemini API + gemini: { + getUsage: async () => { + console.log('[Mock] Getting Gemini usage'); + return { + authenticated: true, + authMethod: 'cli_login', + usedPercent: 0, + remainingPercent: 100, + lastUpdated: new Date().toISOString(), + }; + }, + }, }; }; diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index b65ab872..37b6b416 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -41,7 +41,11 @@ import type { Notification, } from '@automaker/types'; import type { Message, SessionListItem } from '@/types/electron'; -import type { ClaudeUsageResponse, CodexUsageResponse } from '@/store/app-store'; +import type { + ClaudeUsageResponse, + CodexUsageResponse, + GeminiUsage, +} from '@/store/app-store'; import type { WorktreeAPI, GitAPI, ModelDefinition, ProviderStatus } from '@/types/electron'; import type { ModelId, ThinkingLevel, ReasoningEffort, Feature } from '@automaker/types'; import { getGlobalFileBrowser } from '@/contexts/file-browser-context'; @@ -2688,6 +2692,11 @@ export class HttpApiClient implements ElectronAPI { }, }; + // Gemini API + gemini = { + getUsage: (): Promise => this.get('/api/gemini/usage'), + }; + // Context API context = { describeImage: ( diff --git a/apps/ui/src/lib/query-keys.ts b/apps/ui/src/lib/query-keys.ts index aad0208d..70c2679a 100644 --- a/apps/ui/src/lib/query-keys.ts +++ b/apps/ui/src/lib/query-keys.ts @@ -101,6 +101,8 @@ export const queryKeys = { codex: () => ['usage', 'codex'] as const, /** z.ai API usage */ zai: () => ['usage', 'zai'] as const, + /** Gemini API usage */ + gemini: () => ['usage', 'gemini'] as const, }, // ============================================ diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 4d4868b6..cc5fd64b 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -98,6 +98,10 @@ import { type ZaiQuotaLimit, type ZaiUsage, type ZaiUsageResponse, + type GeminiQuotaBucket, + type GeminiTierQuota, + type GeminiUsage, + type GeminiUsageResponse, } from './types'; // Import utility functions from modular utils files @@ -181,6 +185,10 @@ export type { ZaiQuotaLimit, ZaiUsage, ZaiUsageResponse, + GeminiQuotaBucket, + GeminiTierQuota, + GeminiUsage, + GeminiUsageResponse, }; // Re-export values from ./types for backward compatibility @@ -210,7 +218,7 @@ export { defaultBackgroundSettings, defaultTerminalState, MAX_INIT_OUTPUT_LINES // - Terminal types (./types/terminal-types.ts) // - ClaudeModel, Feature, FileTreeNode, ProjectAnalysis (./types/project-types.ts) // - InitScriptState, AutoModeActivity, AppState, AppActions (./types/state-types.ts) -// - Claude/Codex usage types (./types/usage-types.ts) +// - Claude/Codex/Zai/Gemini usage types (./types/usage-types.ts) // The following utility functions have been moved to ./utils/: // - Theme utilities: THEME_STORAGE_KEY, getStoredTheme, getStoredFontSans, getStoredFontMono, etc. (./utils/theme-utils.ts) // - Shortcut utilities: parseShortcut, formatShortcut, DEFAULT_KEYBOARD_SHORTCUTS (./utils/shortcut-utils.ts) @@ -220,6 +228,9 @@ export { defaultBackgroundSettings, defaultTerminalState, MAX_INIT_OUTPUT_LINES // - defaultBackgroundSettings (./defaults/background-settings.ts) // - defaultTerminalState (./defaults/terminal-defaults.ts) +// Type definitions are imported from ./types/state-types.ts +// AppActions interface is defined in ./types/state-types.ts + const initialState: AppState = { projects: [], currentProject: null, diff --git a/apps/ui/src/store/setup-store.ts b/apps/ui/src/store/setup-store.ts index 27a9bdac..aae357ea 100644 --- a/apps/ui/src/store/setup-store.ts +++ b/apps/ui/src/store/setup-store.ts @@ -127,6 +127,22 @@ export interface ZaiAuthStatus { error?: string; } +// Gemini Auth Method +export type GeminiAuthMethod = + | 'cli_login' // Gemini CLI is installed and authenticated + | 'api_key_env' // GOOGLE_API_KEY or GEMINI_API_KEY environment variable + | 'api_key' // Manually stored API key + | 'none'; + +// Gemini Auth Status +export interface GeminiAuthStatus { + authenticated: boolean; + method: GeminiAuthMethod; + hasApiKey?: boolean; + hasEnvApiKey?: boolean; + error?: string; +} + // Claude Auth Method - all possible authentication sources export type ClaudeAuthMethod = | 'oauth_token_env' @@ -200,6 +216,7 @@ export interface SetupState { // Gemini CLI state geminiCliStatus: GeminiCliStatus | null; + geminiAuthStatus: GeminiAuthStatus | null; // Copilot SDK state copilotCliStatus: CopilotCliStatus | null; @@ -243,6 +260,7 @@ export interface SetupActions { // Gemini CLI setGeminiCliStatus: (status: GeminiCliStatus | null) => void; + setGeminiAuthStatus: (status: GeminiAuthStatus | null) => void; // Copilot SDK setCopilotCliStatus: (status: CopilotCliStatus | null) => void; @@ -284,6 +302,7 @@ const initialState: SetupState = { opencodeCliStatus: null, geminiCliStatus: null, + geminiAuthStatus: null, copilotCliStatus: null, @@ -363,6 +382,7 @@ export const useSetupStore = create()((set, get) => ( // Gemini CLI setGeminiCliStatus: (status) => set({ geminiCliStatus: status }), + setGeminiAuthStatus: (status) => set({ geminiAuthStatus: status }), // Copilot SDK setCopilotCliStatus: (status) => set({ copilotCliStatus: status }), diff --git a/apps/ui/src/store/types/usage-types.ts b/apps/ui/src/store/types/usage-types.ts index e7c47a5d..0b6536f3 100644 --- a/apps/ui/src/store/types/usage-types.ts +++ b/apps/ui/src/store/types/usage-types.ts @@ -82,3 +82,55 @@ export interface ZaiUsage { // Response type for z.ai usage API (can be success or error) export type ZaiUsageResponse = ZaiUsage | { error: string; message?: string }; + +// Gemini Usage types - uses internal Google Cloud quota API +export interface GeminiQuotaBucket { + /** Model ID this quota applies to */ + modelId: string; + /** Remaining fraction (0-1) */ + remainingFraction: number; + /** ISO-8601 reset time */ + resetTime: string; +} + +/** Simplified quota info for a model tier (Flash or Pro) */ +export interface GeminiTierQuota { + /** Used percentage (0-100) */ + usedPercent: number; + /** Remaining percentage (0-100) */ + remainingPercent: number; + /** Reset time as human-readable string */ + resetText?: string; + /** ISO-8601 reset time */ + resetTime?: string; +} + +export interface GeminiUsage { + /** Whether the user is authenticated (via CLI or API key) */ + authenticated: boolean; + /** Authentication method: 'cli_login' | 'api_key' | 'api_key_env' | 'none' */ + authMethod: string; + /** Usage percentage (100 - remainingFraction * 100) - overall most constrained */ + usedPercent: number; + /** Remaining percentage - overall most constrained */ + remainingPercent: number; + /** Reset time as human-readable string */ + resetText?: string; + /** ISO-8601 reset time */ + resetTime?: string; + /** Model ID with lowest remaining quota */ + constrainedModel?: string; + /** Flash tier quota (aggregated from all flash models) */ + flashQuota?: GeminiTierQuota; + /** Pro tier quota (aggregated from all pro models) */ + proQuota?: GeminiTierQuota; + /** Raw quota buckets for detailed view */ + quotaBuckets?: GeminiQuotaBucket[]; + /** When this data was last fetched */ + lastUpdated: string; + /** Optional error message */ + error?: string; +} + +// Response type for Gemini usage API (can be success or error) +export type GeminiUsageResponse = GeminiUsage | { error: string; message?: string }; diff --git a/package-lock.json b/package-lock.json index 8804b479..96c4ff7f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11475,7 +11475,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11497,7 +11496,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11519,7 +11517,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11541,7 +11538,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11563,7 +11559,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11585,7 +11580,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11607,7 +11601,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11629,7 +11622,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11651,7 +11643,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11673,7 +11664,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11695,7 +11685,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, From ac2e8cfa882679c3c1cab845f7a6fd9afb4a9f40 Mon Sep 17 00:00:00 2001 From: eclipxe Date: Tue, 20 Jan 2026 14:34:15 -0800 Subject: [PATCH 078/156] Feat: Add z.ai usage tracking --- apps/ui/src/components/usage-popover.tsx | 50 ++++++++++++------------ 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/apps/ui/src/components/usage-popover.tsx b/apps/ui/src/components/usage-popover.tsx index 58c6fd27..bb3b3567 100644 --- a/apps/ui/src/components/usage-popover.tsx +++ b/apps/ui/src/components/usage-popover.tsx @@ -307,16 +307,16 @@ export function UsagePopover() { const codexMaxPercentage = codexUsage?.rateLimits ? Math.max( - codexUsage.rateLimits.primary?.usedPercent || 0, - codexUsage.rateLimits.secondary?.usedPercent || 0 - ) + codexUsage.rateLimits.primary?.usedPercent || 0, + codexUsage.rateLimits.secondary?.usedPercent || 0 + ) : 0; const zaiMaxPercentage = zaiUsage?.quotaLimits ? Math.max( - zaiUsage.quotaLimits.tokens?.usedPercent || 0, - zaiUsage.quotaLimits.mcp?.usedPercent || 0 - ) + zaiUsage.quotaLimits.tokens?.usedPercent || 0, + zaiUsage.quotaLimits.mcp?.usedPercent || 0 + ) : 0; // Gemini quota from Google Cloud API (if available) @@ -346,31 +346,31 @@ export function UsagePopover() { const indicatorInfo = activeTab === 'claude' ? { - icon: AnthropicIcon, - percentage: claudeSessionPercentage, - isStale: isClaudeStale, - title: `Session usage (${CLAUDE_SESSION_WINDOW_HOURS}h window)`, - } + icon: AnthropicIcon, + percentage: claudeSessionPercentage, + isStale: isClaudeStale, + title: `Session usage (${CLAUDE_SESSION_WINDOW_HOURS}h window)`, + } : activeTab === 'codex' ? { - icon: OpenAIIcon, - percentage: codexWindowUsage ?? 0, - isStale: isCodexStale, - } + icon: OpenAIIcon, + percentage: codexWindowUsage ?? 0, + isStale: isCodexStale, + } : activeTab === 'zai' ? { - icon: ZaiIcon, - percentage: zaiMaxPercentage, - isStale: isZaiStale, - title: `Usage (z.ai)`, - } + icon: ZaiIcon, + percentage: zaiMaxPercentage, + isStale: isZaiStale, + title: `Usage (z.ai)`, + } : activeTab === 'gemini' ? { - icon: GeminiIcon, - percentage: geminiMaxPercentage, - isStale: isGeminiStale, - title: `Usage (Gemini)`, - } + icon: GeminiIcon, + percentage: geminiMaxPercentage, + isStale: isGeminiStale, + title: `Usage (Gemini)`, + } : null; const statusColor = getStatusInfo(indicatorInfo.percentage).color; From 41014f6ab6ccc96ba13b8e15edbcfea5403bc75f Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 15 Feb 2026 21:04:18 -0800 Subject: [PATCH 079/156] fix: resolve TypeScript errors after upstream merge Add missing 'adaptive' thinking level to kanban card labels and export TerminalPromptTheme type from @automaker/types package. Co-Authored-By: Claude Opus 4.6 --- .../board-view/components/kanban-card/agent-info-panel.tsx | 1 + libs/types/src/index.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx index 03b2b0f5..a3540cd7 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx @@ -22,6 +22,7 @@ function formatThinkingLevel(level: ThinkingLevel | undefined): string { medium: 'Med', high: 'High', ultrathink: 'Ultra', + adaptive: 'Adaptive', }; return labels[level]; } diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts index e9193327..e0d21470 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -183,6 +183,8 @@ export type { // Claude API profile types (deprecated) ClaudeApiProfile, ClaudeApiProfileTemplate, + // Terminal prompt theme type + TerminalPromptTheme, } from './settings.js'; export { DEFAULT_KEYBOARD_SHORTCUTS, From e9802ac00c3f7b34e839f913640b97b6839d8e6e Mon Sep 17 00:00:00 2001 From: eclipxe Date: Wed, 21 Jan 2026 08:29:20 -0800 Subject: [PATCH 080/156] Feat: Add ability to duplicate a feature and duplicate as a child --- .../src/routes/features/routes/create.ts | 13 - .../src/routes/features/routes/update.ts | 17 -- apps/ui/src/components/views/board-view.tsx | 4 + .../components/kanban-card/card-header.tsx | 228 +++++++++++++++--- .../components/kanban-card/kanban-card.tsx | 6 + .../components/list-view/list-view.tsx | 14 ++ .../components/list-view/row-actions.tsx | 137 +++++++++++ .../board-view/hooks/use-board-actions.ts | 21 ++ .../views/board-view/kanban-board.tsx | 6 + 9 files changed, 386 insertions(+), 60 deletions(-) diff --git a/apps/server/src/routes/features/routes/create.ts b/apps/server/src/routes/features/routes/create.ts index 29f7d075..c607e72e 100644 --- a/apps/server/src/routes/features/routes/create.ts +++ b/apps/server/src/routes/features/routes/create.ts @@ -24,19 +24,6 @@ export function createCreateHandler(featureLoader: FeatureLoader, events?: Event return; } - // Check for duplicate title if title is provided - if (feature.title && feature.title.trim()) { - const duplicate = await featureLoader.findDuplicateTitle(projectPath, feature.title); - if (duplicate) { - res.status(409).json({ - success: false, - error: `A feature with title "${feature.title}" already exists`, - duplicateFeatureId: duplicate.id, - }); - return; - } - } - const created = await featureLoader.create(projectPath, feature); // Emit feature_created event for hooks diff --git a/apps/server/src/routes/features/routes/update.ts b/apps/server/src/routes/features/routes/update.ts index a5b532c1..4d5e7a00 100644 --- a/apps/server/src/routes/features/routes/update.ts +++ b/apps/server/src/routes/features/routes/update.ts @@ -40,23 +40,6 @@ export function createUpdateHandler(featureLoader: FeatureLoader) { return; } - // Check for duplicate title if title is being updated - if (updates.title && updates.title.trim()) { - const duplicate = await featureLoader.findDuplicateTitle( - projectPath, - updates.title, - featureId // Exclude the current feature from duplicate check - ); - if (duplicate) { - res.status(409).json({ - success: false, - error: `A feature with title "${updates.title}" already exists`, - duplicateFeatureId: duplicate.id, - }); - return; - } - } - // Get the current feature to detect status changes const currentFeature = await featureLoader.get(projectPath, featureId); const previousStatus = currentFeature?.status as FeatureStatus | undefined; diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index d8be006d..1266ea77 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -590,6 +590,7 @@ export function BoardView() { handleForceStopFeature, handleStartNextFeatures, handleArchiveAllVerified, + handleDuplicateFeature, } = useBoardActions({ currentProject, features: hookFeatures, @@ -1465,6 +1466,8 @@ export function BoardView() { setSpawnParentFeature(feature); setShowAddDialog(true); }, + onDuplicate: (feature) => handleDuplicateFeature(feature, false), + onDuplicateAsChild: (feature) => handleDuplicateFeature(feature, true), }} runningAutoTasks={runningAutoTasks} pipelineConfig={pipelineConfig} @@ -1504,6 +1507,7 @@ export function BoardView() { setSpawnParentFeature(feature); setShowAddDialog(true); }} + onDuplicate={handleDuplicateFeature} featuresWithContext={featuresWithContext} runningAutoTasks={runningAutoTasks} onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)} diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx index 793c3191..e3575c55 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx @@ -8,6 +8,9 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { @@ -19,6 +22,7 @@ import { ChevronDown, ChevronUp, GitFork, + Copy, } from 'lucide-react'; import { Spinner } from '@/components/ui/spinner'; import { CountUpTimer } from '@/components/ui/count-up-timer'; @@ -35,6 +39,8 @@ interface CardHeaderProps { onDelete: () => void; onViewOutput?: () => void; onSpawnTask?: () => void; + onDuplicate?: () => void; + onDuplicateAsChild?: () => void; } export const CardHeaderSection = memo(function CardHeaderSection({ @@ -46,6 +52,8 @@ export const CardHeaderSection = memo(function CardHeaderSection({ onDelete, onViewOutput, onSpawnTask, + onDuplicate, + onDuplicateAsChild, }: CardHeaderProps) { const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); @@ -109,6 +117,39 @@ export const CardHeaderSection = memo(function CardHeaderSection({ Spawn Sub-Task + {onDuplicate && ( + +
+ { + e.stopPropagation(); + onDuplicate(); + }} + className="text-xs flex-1 pr-0 rounded-r-none" + > + + Duplicate + + {onDuplicateAsChild && ( + + )} +
+ {onDuplicateAsChild && ( + + { + e.stopPropagation(); + onDuplicateAsChild(); + }} + className="text-xs" + > + + Duplicate as Child + + + )} +
+ )} {/* Model info in dropdown */} {(() => { const ProviderIcon = getProviderIconForModel(feature.model); @@ -129,20 +170,6 @@ export const CardHeaderSection = memo(function CardHeaderSection({ {/* Backlog header */} {!isCurrentAutoTask && !isSelectionMode && feature.status === 'backlog' && (
- + + + + + + { + e.stopPropagation(); + onSpawnTask?.(); + }} + data-testid={`spawn-backlog-${feature.id}`} + className="text-xs" + > + + Spawn Sub-Task + + {onDuplicate && ( + +
+ { + e.stopPropagation(); + onDuplicate(); + }} + className="text-xs flex-1 pr-0 rounded-r-none" + > + + Duplicate + + {onDuplicateAsChild && ( + + )} +
+ {onDuplicateAsChild && ( + + { + e.stopPropagation(); + onDuplicateAsChild(); + }} + className="text-xs" + > + + Duplicate as Child + + + )} +
+ )} +
+
)} @@ -178,22 +265,6 @@ export const CardHeaderSection = memo(function CardHeaderSection({ > - {onViewOutput && ( + + + + + + { + e.stopPropagation(); + onSpawnTask?.(); + }} + data-testid={`spawn-${ + feature.status === 'waiting_approval' ? 'waiting' : 'verified' + }-${feature.id}`} + className="text-xs" + > + + Spawn Sub-Task + + {onDuplicate && ( + +
+ { + e.stopPropagation(); + onDuplicate(); + }} + className="text-xs flex-1 pr-0 rounded-r-none" + > + + Duplicate + + {onDuplicateAsChild && ( + + )} +
+ {onDuplicateAsChild && ( + + { + e.stopPropagation(); + onDuplicateAsChild(); + }} + className="text-xs" + > + + Duplicate as Child + + + )} +
+ )} +
+
)} @@ -293,6 +428,39 @@ export const CardHeaderSection = memo(function CardHeaderSection({ Spawn Sub-Task + {onDuplicate && ( + +
+ { + e.stopPropagation(); + onDuplicate(); + }} + className="text-xs flex-1 pr-0 rounded-r-none" + > + + Duplicate + + {onDuplicateAsChild && ( + + )} +
+ {onDuplicateAsChild && ( + + { + e.stopPropagation(); + onDuplicateAsChild(); + }} + className="text-xs" + > + + Duplicate as Child + + + )} +
+ )} {/* Model info in dropdown */} {(() => { const ProviderIcon = getProviderIconForModel(feature.model); diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx index a332f305..4859331f 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx @@ -52,6 +52,8 @@ interface KanbanCardProps { onViewPlan?: () => void; onApprovePlan?: () => void; onSpawnTask?: () => void; + onDuplicate?: () => void; + onDuplicateAsChild?: () => void; hasContext?: boolean; isCurrentAutoTask?: boolean; shortcutKey?: string; @@ -86,6 +88,8 @@ export const KanbanCard = memo(function KanbanCard({ onViewPlan, onApprovePlan, onSpawnTask, + onDuplicate, + onDuplicateAsChild, hasContext, isCurrentAutoTask, shortcutKey, @@ -249,6 +253,8 @@ export const KanbanCard = memo(function KanbanCard({ onDelete={onDelete} onViewOutput={onViewOutput} onSpawnTask={onSpawnTask} + onDuplicate={onDuplicate} + onDuplicateAsChild={onDuplicateAsChild} /> diff --git a/apps/ui/src/components/views/board-view/components/list-view/list-view.tsx b/apps/ui/src/components/views/board-view/components/list-view/list-view.tsx index 0a08b127..cac687eb 100644 --- a/apps/ui/src/components/views/board-view/components/list-view/list-view.tsx +++ b/apps/ui/src/components/views/board-view/components/list-view/list-view.tsx @@ -42,6 +42,8 @@ export interface ListViewActionHandlers { onViewPlan?: (feature: Feature) => void; onApprovePlan?: (feature: Feature) => void; onSpawnTask?: (feature: Feature) => void; + onDuplicate?: (feature: Feature) => void; + onDuplicateAsChild?: (feature: Feature) => void; } export interface ListViewProps { @@ -313,6 +315,18 @@ export const ListView = memo(function ListView({ if (f) actionHandlers.onSpawnTask?.(f); } : undefined, + duplicate: actionHandlers.onDuplicate + ? (id) => { + const f = allFeatures.find((f) => f.id === id); + if (f) actionHandlers.onDuplicate?.(f); + } + : undefined, + duplicateAsChild: actionHandlers.onDuplicateAsChild + ? (id) => { + const f = allFeatures.find((f) => f.id === id); + if (f) actionHandlers.onDuplicateAsChild?.(f); + } + : undefined, }); }, [actionHandlers, allFeatures] diff --git a/apps/ui/src/components/views/board-view/components/list-view/row-actions.tsx b/apps/ui/src/components/views/board-view/components/list-view/row-actions.tsx index bb5c53d1..60158d0f 100644 --- a/apps/ui/src/components/views/board-view/components/list-view/row-actions.tsx +++ b/apps/ui/src/components/views/board-view/components/list-view/row-actions.tsx @@ -14,6 +14,7 @@ import { GitBranch, GitFork, ExternalLink, + Copy, } from 'lucide-react'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; @@ -22,6 +23,9 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import type { Feature } from '@/store/app-store'; @@ -43,6 +47,8 @@ export interface RowActionHandlers { onViewPlan?: () => void; onApprovePlan?: () => void; onSpawnTask?: () => void; + onDuplicate?: () => void; + onDuplicateAsChild?: () => void; } export interface RowActionsProps { @@ -405,6 +411,31 @@ export const RowActions = memo(function RowActions({ onClick={withClose(handlers.onSpawnTask)} /> )} + {handlers.onDuplicate && ( + +
+ + + Duplicate + + {handlers.onDuplicateAsChild && ( + + )} +
+ {handlers.onDuplicateAsChild && ( + + + + )} +
+ )} )} + {handlers.onDuplicate && ( + +
+ + + Duplicate + + {handlers.onDuplicateAsChild && ( + + )} +
+ {handlers.onDuplicateAsChild && ( + + + + )} +
+ )} )} + {handlers.onDuplicate && ( + +
+ + + Duplicate + + {handlers.onDuplicateAsChild && ( + + )} +
+ {handlers.onDuplicateAsChild && ( + + + + )} +
+ )} )} + {handlers.onDuplicate && ( + +
+ + + Duplicate + + {handlers.onDuplicateAsChild && ( + + )} +
+ {handlers.onDuplicateAsChild && ( + + + + )} +
+ )} )} + {handlers.onDuplicate && ( + +
+ + + Duplicate + + {handlers.onDuplicateAsChild && ( + + )} +
+ {handlers.onDuplicateAsChild && ( + + + + )} +
+ )} void; approvePlan?: (id: string) => void; spawnTask?: (id: string) => void; + duplicate?: (id: string) => void; + duplicateAsChild?: (id: string) => void; } ): RowActionHandlers { return { @@ -631,5 +764,9 @@ export function createRowActionHandlers( onViewPlan: actions.viewPlan ? () => actions.viewPlan!(featureId) : undefined, onApprovePlan: actions.approvePlan ? () => actions.approvePlan!(featureId) : undefined, onSpawnTask: actions.spawnTask ? () => actions.spawnTask!(featureId) : undefined, + onDuplicate: actions.duplicate ? () => actions.duplicate!(featureId) : undefined, + onDuplicateAsChild: actions.duplicateAsChild + ? () => actions.duplicateAsChild!(featureId) + : undefined, }; } diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts b/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts index ebd80591..4f3c0517 100644 --- a/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts +++ b/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts @@ -1083,6 +1083,26 @@ export function useBoardActions({ }); }, [features, runningAutoTasks, autoMode, updateFeature, persistFeatureUpdate]); + const handleDuplicateFeature = useCallback( + async (feature: Feature, asChild: boolean = false) => { + // Copy all feature data, only override id/status (handled by create) and dependencies if as child + const { id: _id, status: _status, ...featureData } = feature; + const duplicatedFeatureData = { + ...featureData, + // If duplicating as child, set source as dependency; otherwise keep existing + ...(asChild && { dependencies: [feature.id] }), + }; + + // Reuse the existing handleAddFeature logic + await handleAddFeature(duplicatedFeatureData); + + toast.success(asChild ? 'Duplicated as child' : 'Feature duplicated', { + description: `Created copy of: ${truncateDescription(feature.description || feature.title || '')}`, + }); + }, + [handleAddFeature] + ); + return { handleAddFeature, handleUpdateFeature, @@ -1103,5 +1123,6 @@ export function useBoardActions({ handleForceStopFeature, handleStartNextFeatures, handleArchiveAllVerified, + handleDuplicateFeature, }; } diff --git a/apps/ui/src/components/views/board-view/kanban-board.tsx b/apps/ui/src/components/views/board-view/kanban-board.tsx index 7f857392..ef091872 100644 --- a/apps/ui/src/components/views/board-view/kanban-board.tsx +++ b/apps/ui/src/components/views/board-view/kanban-board.tsx @@ -46,6 +46,7 @@ interface KanbanBoardProps { onViewPlan: (feature: Feature) => void; onApprovePlan: (feature: Feature) => void; onSpawnTask?: (feature: Feature) => void; + onDuplicate?: (feature: Feature, asChild: boolean) => void; featuresWithContext: Set; runningAutoTasks: string[]; onArchiveAllVerified: () => void; @@ -282,6 +283,7 @@ export function KanbanBoard({ onViewPlan, onApprovePlan, onSpawnTask, + onDuplicate, featuresWithContext, runningAutoTasks, onArchiveAllVerified, @@ -569,6 +571,8 @@ export function KanbanBoard({ onViewPlan={() => onViewPlan(feature)} onApprovePlan={() => onApprovePlan(feature)} onSpawnTask={() => onSpawnTask?.(feature)} + onDuplicate={() => onDuplicate?.(feature, false)} + onDuplicateAsChild={() => onDuplicate?.(feature, true)} hasContext={featuresWithContext.has(feature.id)} isCurrentAutoTask={runningAutoTasks.includes(feature.id)} shortcutKey={shortcutKey} @@ -611,6 +615,8 @@ export function KanbanBoard({ onViewPlan={() => onViewPlan(feature)} onApprovePlan={() => onApprovePlan(feature)} onSpawnTask={() => onSpawnTask?.(feature)} + onDuplicate={() => onDuplicate?.(feature, false)} + onDuplicateAsChild={() => onDuplicate?.(feature, true)} hasContext={featuresWithContext.has(feature.id)} isCurrentAutoTask={runningAutoTasks.includes(feature.id)} shortcutKey={shortcutKey} From bea26a6b6125d359c13956511c7b4431ff0bf723 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 15 Feb 2026 22:50:01 -0800 Subject: [PATCH 081/156] style: Fix inconsistent indentation in components and imports --- apps/ui/src/components/usage-popover.tsx | 50 ++++++++++++------------ apps/ui/src/lib/http-api-client.ts | 6 +-- 2 files changed, 26 insertions(+), 30 deletions(-) diff --git a/apps/ui/src/components/usage-popover.tsx b/apps/ui/src/components/usage-popover.tsx index bb3b3567..58c6fd27 100644 --- a/apps/ui/src/components/usage-popover.tsx +++ b/apps/ui/src/components/usage-popover.tsx @@ -307,16 +307,16 @@ export function UsagePopover() { const codexMaxPercentage = codexUsage?.rateLimits ? Math.max( - codexUsage.rateLimits.primary?.usedPercent || 0, - codexUsage.rateLimits.secondary?.usedPercent || 0 - ) + codexUsage.rateLimits.primary?.usedPercent || 0, + codexUsage.rateLimits.secondary?.usedPercent || 0 + ) : 0; const zaiMaxPercentage = zaiUsage?.quotaLimits ? Math.max( - zaiUsage.quotaLimits.tokens?.usedPercent || 0, - zaiUsage.quotaLimits.mcp?.usedPercent || 0 - ) + zaiUsage.quotaLimits.tokens?.usedPercent || 0, + zaiUsage.quotaLimits.mcp?.usedPercent || 0 + ) : 0; // Gemini quota from Google Cloud API (if available) @@ -346,31 +346,31 @@ export function UsagePopover() { const indicatorInfo = activeTab === 'claude' ? { - icon: AnthropicIcon, - percentage: claudeSessionPercentage, - isStale: isClaudeStale, - title: `Session usage (${CLAUDE_SESSION_WINDOW_HOURS}h window)`, - } + icon: AnthropicIcon, + percentage: claudeSessionPercentage, + isStale: isClaudeStale, + title: `Session usage (${CLAUDE_SESSION_WINDOW_HOURS}h window)`, + } : activeTab === 'codex' ? { - icon: OpenAIIcon, - percentage: codexWindowUsage ?? 0, - isStale: isCodexStale, - } + icon: OpenAIIcon, + percentage: codexWindowUsage ?? 0, + isStale: isCodexStale, + } : activeTab === 'zai' ? { - icon: ZaiIcon, - percentage: zaiMaxPercentage, - isStale: isZaiStale, - title: `Usage (z.ai)`, - } + icon: ZaiIcon, + percentage: zaiMaxPercentage, + isStale: isZaiStale, + title: `Usage (z.ai)`, + } : activeTab === 'gemini' ? { - icon: GeminiIcon, - percentage: geminiMaxPercentage, - isStale: isGeminiStale, - title: `Usage (Gemini)`, - } + icon: GeminiIcon, + percentage: geminiMaxPercentage, + isStale: isGeminiStale, + title: `Usage (Gemini)`, + } : null; const statusColor = getStatusInfo(indicatorInfo.percentage).color; diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 37b6b416..4238f58e 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -41,11 +41,7 @@ import type { Notification, } from '@automaker/types'; import type { Message, SessionListItem } from '@/types/electron'; -import type { - ClaudeUsageResponse, - CodexUsageResponse, - GeminiUsage, -} from '@/store/app-store'; +import type { ClaudeUsageResponse, CodexUsageResponse, GeminiUsage } from '@/store/app-store'; import type { WorktreeAPI, GitAPI, ModelDefinition, ProviderStatus } from '@/types/electron'; import type { ModelId, ThinkingLevel, ReasoningEffort, Feature } from '@automaker/types'; import { getGlobalFileBrowser } from '@/contexts/file-browser-context'; From eed5e20438b66e7a4d81dfeac6c1dc035a08115e Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 16 Feb 2026 10:47:52 -0800 Subject: [PATCH 082/156] fix(agent-service): fallback to effectiveModel when requestedModel is undefined --- apps/server/src/services/agent-service.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/server/src/services/agent-service.ts b/apps/server/src/services/agent-service.ts index e5458a98..0ecec44e 100644 --- a/apps/server/src/services/agent-service.ts +++ b/apps/server/src/services/agent-service.ts @@ -389,14 +389,16 @@ export class AgentService { // Get provider for this model (with prefix) // When using custom provider (GLM, MiniMax), requestedModel routes to Claude provider - const modelForProvider = claudeCompatibleProvider ? requestedModel : effectiveModel; + const modelForProvider = claudeCompatibleProvider + ? (requestedModel ?? effectiveModel) + : effectiveModel; const provider = ProviderFactory.getProviderForModel(modelForProvider); // Strip provider prefix - providers should receive bare model IDs // CRITICAL: For custom providers (GLM, MiniMax), pass the provider's model ID (e.g. "GLM-4.7") // to the API, NOT the resolved Claude model - otherwise we get "model not found" - const bareModel = claudeCompatibleProvider - ? requestedModel + const bareModel: string = claudeCompatibleProvider + ? (requestedModel ?? effectiveModel) : stripProviderPrefix(effectiveModel); // Build options for provider From 462dbf1522d91bc2e7610d7b6e19b172bc50f87e Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 16 Feb 2026 11:53:09 -0800 Subject: [PATCH 083/156] fix: Address code review comments --- .../routes/auto-mode/routes/approve-plan.ts | 12 +++- .../server/src/routes/features/routes/list.ts | 23 ++++--- .../routes/github/routes/validate-issue.ts | 5 +- apps/server/src/services/agent-executor.ts | 2 + .../src/services/auto-loop-coordinator.ts | 20 ++++-- apps/server/src/services/auto-mode/facade.ts | 67 +++++++++++-------- apps/server/src/services/execution-service.ts | 9 ++- .../src/services/feature-state-manager.ts | 7 ++ apps/server/src/services/merge-service.ts | 44 +++++++----- .../src/services/pipeline-orchestrator.ts | 3 +- .../src/services/plan-approval-service.ts | 13 +++- .../src/components/views/graph-view-page.tsx | 16 +++-- 12 files changed, 147 insertions(+), 74 deletions(-) diff --git a/apps/server/src/routes/auto-mode/routes/approve-plan.ts b/apps/server/src/routes/auto-mode/routes/approve-plan.ts index 277b50e2..14673e31 100644 --- a/apps/server/src/routes/auto-mode/routes/approve-plan.ts +++ b/apps/server/src/routes/auto-mode/routes/approve-plan.ts @@ -17,7 +17,7 @@ export function createApprovePlanHandler(autoModeService: AutoModeServiceCompat) approved: boolean; editedPlan?: string; feedback?: string; - projectPath?: string; + projectPath: string; }; if (!featureId) { @@ -36,6 +36,14 @@ export function createApprovePlanHandler(autoModeService: AutoModeServiceCompat) return; } + if (!projectPath) { + res.status(400).json({ + success: false, + error: 'projectPath is required', + }); + return; + } + // Note: We no longer check hasPendingApproval here because resolvePlanApproval // can handle recovery when pending approval is not in Map but feature has planSpec.status='generated' // This supports cases where the server restarted while waiting for approval @@ -48,7 +56,7 @@ export function createApprovePlanHandler(autoModeService: AutoModeServiceCompat) // Resolve the pending approval (with recovery support) const result = await autoModeService.resolvePlanApproval( - projectPath || '', + projectPath, featureId, approved, editedPlan, diff --git a/apps/server/src/routes/features/routes/list.ts b/apps/server/src/routes/features/routes/list.ts index 766e625c..c0f22d33 100644 --- a/apps/server/src/routes/features/routes/list.ts +++ b/apps/server/src/routes/features/routes/list.ts @@ -33,18 +33,23 @@ export function createListHandler( // We don't await this to keep the list response fast // Note: detectOrphanedFeatures handles errors internally and always resolves if (autoModeService) { - autoModeService.detectOrphanedFeatures(projectPath).then((orphanedFeatures) => { - if (orphanedFeatures.length > 0) { - logger.info( - `[ProjectLoad] Detected ${orphanedFeatures.length} orphaned feature(s) in ${projectPath}` - ); - for (const { feature, missingBranch } of orphanedFeatures) { + autoModeService + .detectOrphanedFeatures(projectPath) + .then((orphanedFeatures) => { + if (orphanedFeatures.length > 0) { logger.info( - `[ProjectLoad] Orphaned: ${feature.title || feature.id} - branch "${missingBranch}" no longer exists` + `[ProjectLoad] Detected ${orphanedFeatures.length} orphaned feature(s) in ${projectPath}` ); + for (const { feature, missingBranch } of orphanedFeatures) { + logger.info( + `[ProjectLoad] Orphaned: ${feature.title || feature.id} - branch "${missingBranch}" no longer exists` + ); + } } - } - }); + }) + .catch((error) => { + logger.warn(`[ProjectLoad] Orphan detection failed for ${projectPath}:`, error); + }); } res.json({ success: true, features }); diff --git a/apps/server/src/routes/github/routes/validate-issue.ts b/apps/server/src/routes/github/routes/validate-issue.ts index 9f3af5cf..38220f6d 100644 --- a/apps/server/src/routes/github/routes/validate-issue.ts +++ b/apps/server/src/routes/github/routes/validate-issue.ts @@ -25,7 +25,7 @@ import { isOpencodeModel, supportsStructuredOutput, } from '@automaker/types'; -import { resolvePhaseModel } from '@automaker/model-resolver'; +import { resolvePhaseModel, resolveModelString } from '@automaker/model-resolver'; import { extractJson } from '../../../lib/json-extractor.js'; import { writeValidation } from '../../../lib/validation-storage.js'; import { streamingQuery } from '../../../providers/simple-query-service.js'; @@ -190,9 +190,10 @@ ${basePrompt}`; // CRITICAL: For custom providers (GLM, MiniMax), pass the provider's model ID (e.g. "GLM-4.7") // to the API, NOT the resolved Claude model - otherwise we get "model not found" + // For standard Claude models, resolve aliases (e.g., 'opus' -> 'claude-opus-4-20250514') const effectiveModel = claudeCompatibleProvider ? (model as string) - : providerResolvedModel || (model as string); + : providerResolvedModel || resolveModelString(model as string); logger.info(`Using model: ${effectiveModel}`); // Use streamingQuery with event callbacks diff --git a/apps/server/src/services/agent-executor.ts b/apps/server/src/services/agent-executor.ts index 1ccb7497..dd1c179c 100644 --- a/apps/server/src/services/agent-executor.ts +++ b/apps/server/src/services/agent-executor.ts @@ -560,6 +560,7 @@ export class AgentExecutor { revText += b.text || ''; this.eventBus.emitAutoModeEvent('auto_mode_progress', { featureId, + branchName, content: b.text, }); } @@ -638,6 +639,7 @@ export class AgentExecutor { cwd: o.workDir, allowedTools: o.sdkOptions?.allowedTools as string[] | undefined, abortController: o.abortController, + thinkingLevel: o.thinkingLevel, mcpServers: o.mcpServers && Object.keys(o.mcpServers).length > 0 ? (o.mcpServers as Record) diff --git a/apps/server/src/services/auto-loop-coordinator.ts b/apps/server/src/services/auto-loop-coordinator.ts index 0b03e5f8..3e63cff1 100644 --- a/apps/server/src/services/auto-loop-coordinator.ts +++ b/apps/server/src/services/auto-loop-coordinator.ts @@ -31,8 +31,16 @@ export interface ProjectAutoLoopState { branchName: string | null; } +/** + * Generate a unique key for a worktree auto-loop instance. + * + * When branchName is null, this represents the main worktree (uses '__main__' sentinel). + * Named branches always use their exact name β€” the caller is responsible for passing + * null for the primary branch (main/master/etc.) so key matching stays consistent + * with ConcurrencyManager's dynamic primary branch resolution. + */ export function getWorktreeAutoLoopKey(projectPath: string, branchName: string | null): string { - return `${projectPath}::${(branchName === 'main' ? null : branchName) ?? '__main__'}`; + return `${projectPath}::${branchName ?? '__main__'}`; } export type ExecuteFeatureFn = ( @@ -404,11 +412,15 @@ export class AutoLoopCoordinator { reject(new Error('Aborted')); return; } - const timeout = setTimeout(resolve, ms); - signal?.addEventListener('abort', () => { + const onAbort = () => { clearTimeout(timeout); reject(new Error('Aborted')); - }); + }; + const timeout = setTimeout(() => { + signal?.removeEventListener('abort', onAbort); + resolve(); + }, ms); + signal?.addEventListener('abort', onAbort); }); } } diff --git a/apps/server/src/services/auto-mode/facade.ts b/apps/server/src/services/auto-mode/facade.ts index 83acf678..af909d30 100644 --- a/apps/server/src/services/auto-mode/facade.ts +++ b/apps/server/src/services/auto-mode/facade.ts @@ -17,7 +17,7 @@ import { promisify } from 'util'; import type { Feature, PlanningMode, ThinkingLevel } from '@automaker/types'; import { DEFAULT_MAX_CONCURRENCY, stripProviderPrefix } from '@automaker/types'; import { createLogger, loadContextFiles, classifyError } from '@automaker/utils'; -import { getFeatureDir } from '@automaker/platform'; +import { getFeatureDir, spawnProcess } from '@automaker/platform'; import * as secureFs from '../../lib/secure-fs.js'; import { validateWorkingDirectory } from '../../lib/sdk-options.js'; import { getPromptCustomization, getProviderByModelId } from '../../lib/settings-helpers.js'; @@ -48,6 +48,24 @@ import type { const execAsync = promisify(exec); const logger = createLogger('AutoModeServiceFacade'); +/** + * Execute git command with array arguments to prevent command injection. + */ +async function execGitCommand(args: string[], cwd: string): Promise { + const result = await spawnProcess({ + command: 'git', + args, + cwd, + }); + + if (result.exitCode === 0) { + return result.stdout; + } else { + const errorMessage = result.stderr || `Git command failed with code ${result.exitCode}`; + throw new Error(errorMessage); + } +} + /** * AutoModeServiceFacade provides a clean interface for auto-mode functionality. * @@ -589,19 +607,8 @@ ${prompt} Address the follow-up instructions above. Review the previous work and make the requested changes or fixes.`; try { - this.eventBus.emitAutoModeEvent('auto_mode_feature_start', { - featureId, - projectPath: this.projectPath, - branchName: feature?.branchName ?? null, - feature: { - id: featureId, - title: feature?.title || 'Follow-up', - description: feature?.description || 'Following up on feature', - }, - }); - // NOTE: Facade does not have runAgent - this method requires AutoModeService - // For now, throw to indicate routes should use AutoModeService.followUpFeature + // Do NOT emit start events before throwing to prevent false start events throw new Error( 'followUpFeature not fully implemented in facade - use AutoModeService.followUpFeature instead' ); @@ -691,18 +698,22 @@ Address the follow-up instructions above. Review the previous work and make the // Use project path } } else { - const sanitizedFeatureId = featureId.replace(/[^a-zA-Z0-9_-]/g, '-'); - const legacyWorktreePath = path.join(this.projectPath, '.worktrees', sanitizedFeatureId); - try { - await secureFs.access(legacyWorktreePath); - workDir = legacyWorktreePath; - } catch { - // Use project path + // Use worktreeResolver instead of manual .worktrees lookup + const feature = await this.featureStateManager.loadFeature(this.projectPath, featureId); + const branchName = feature?.branchName; + if (branchName) { + const resolved = await this.worktreeResolver.findWorktreeForBranch( + this.projectPath, + branchName + ); + if (resolved) { + workDir = resolved; + } } } try { - const { stdout: status } = await execAsync('git status --porcelain', { cwd: workDir }); + const status = await execGitCommand(['status', '--porcelain'], workDir); if (!status.trim()) { return null; } @@ -712,9 +723,9 @@ Address the follow-up instructions above. Review the previous work and make the feature?.description?.split('\n')[0]?.substring(0, 60) || `Feature ${featureId}`; const commitMessage = `feat: ${title}\n\nImplemented by Automaker auto-mode`; - await execAsync('git add -A', { cwd: workDir }); - await execAsync(`git commit -m "${commitMessage.replace(/"/g, '\\"')}"`, { cwd: workDir }); - const { stdout: hash } = await execAsync('git rev-parse HEAD', { cwd: workDir }); + await execGitCommand(['add', '-A'], workDir); + await execGitCommand(['commit', '-m', commitMessage], workDir); + const hash = await execGitCommand(['rev-parse', 'HEAD'], workDir); this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', { featureId, @@ -975,10 +986,10 @@ Address the follow-up instructions above. Review the previous work and make the return orphanedFeatures; } - // Get existing branches - const { stdout } = await execAsync( - 'git for-each-ref --format="%(refname:short)" refs/heads/', - { cwd: this.projectPath } + // Get existing branches (using safe array-based command) + const stdout = await execGitCommand( + ['for-each-ref', '--format=%(refname:short)', 'refs/heads/'], + this.projectPath ); const existingBranches = new Set( stdout diff --git a/apps/server/src/services/execution-service.ts b/apps/server/src/services/execution-service.ts index 54e8edd6..2af35fe7 100644 --- a/apps/server/src/services/execution-service.ts +++ b/apps/server/src/services/execution-service.ts @@ -190,9 +190,9 @@ ${feature.spec} } } - let worktreePath: string | null = null; + let worktreePath: string | null = providedWorktreePath ?? null; const branchName = feature.branchName; - if (useWorktrees && branchName) { + if (!worktreePath && useWorktrees && branchName) { worktreePath = await this.worktreeResolver.findWorktreeForBranch(projectPath, branchName); if (worktreePath) logger.info(`Using worktree for branch "${branchName}": ${worktreePath}`); } @@ -289,6 +289,11 @@ ${feature.spec} testAttempts: 0, maxTestAttempts: 5, }); + // Check if pipeline set a terminal status (e.g., merge_conflict) β€” don't overwrite it + const refreshed = await this.loadFeatureFn(projectPath, featureId); + if (refreshed?.status === 'merge_conflict') { + return; + } } const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified'; diff --git a/apps/server/src/services/feature-state-manager.ts b/apps/server/src/services/feature-state-manager.ts index e7f37962..c74ad88b 100644 --- a/apps/server/src/services/feature-state-manager.ts +++ b/apps/server/src/services/feature-state-manager.ts @@ -115,6 +115,13 @@ export class FeatureStateManager { // PERSIST BEFORE EMIT (Pitfall 2) await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT }); + // Emit status change event so UI can react without polling + this.emitAutoModeEvent('feature_status_changed', { + featureId, + projectPath, + status, + }); + // Create notifications for important status changes const notificationService = getNotificationService(); if (status === 'waiting_approval') { diff --git a/apps/server/src/services/merge-service.ts b/apps/server/src/services/merge-service.ts index 837fbfbb..087aa801 100644 --- a/apps/server/src/services/merge-service.ts +++ b/apps/server/src/services/merge-service.ts @@ -4,12 +4,8 @@ * Extracted from worktree merge route to allow internal service calls. */ -import { exec } from 'child_process'; -import { promisify } from 'util'; import { createLogger } from '@automaker/utils'; import { spawnProcess } from '@automaker/platform'; - -const execAsync = promisify(exec); const logger = createLogger('MergeService'); export interface MergeOptions { @@ -80,9 +76,23 @@ export async function performMerge( const mergeTo = targetBranch || 'main'; - // Validate source branch exists + // Validate branch names early to reject invalid input before any git operations + if (!isValidBranchName(branchName)) { + return { + success: false, + error: `Invalid source branch name: "${branchName}"`, + }; + } + if (!isValidBranchName(mergeTo)) { + return { + success: false, + error: `Invalid target branch name: "${mergeTo}"`, + }; + } + + // Validate source branch exists (using safe array-based command) try { - await execAsync(`git rev-parse --verify ${branchName}`, { cwd: projectPath }); + await execGitCommand(['rev-parse', '--verify', branchName], projectPath); } catch { return { success: false, @@ -90,9 +100,9 @@ export async function performMerge( }; } - // Validate target branch exists + // Validate target branch exists (using safe array-based command) try { - await execAsync(`git rev-parse --verify ${mergeTo}`, { cwd: projectPath }); + await execGitCommand(['rev-parse', '--verify', mergeTo], projectPath); } catch { return { success: false, @@ -100,13 +110,14 @@ export async function performMerge( }; } - // Merge the feature branch into the target branch - const mergeCmd = options?.squash - ? `git merge --squash ${branchName}` - : `git merge ${branchName} -m "${options?.message || `Merge ${branchName} into ${mergeTo}`}"`; + // Merge the feature branch into the target branch (using safe array-based commands) + const mergeMessage = options?.message || `Merge ${branchName} into ${mergeTo}`; + const mergeArgs = options?.squash + ? ['merge', '--squash', branchName] + : ['merge', branchName, '-m', mergeMessage]; try { - await execAsync(mergeCmd, { cwd: projectPath }); + await execGitCommand(mergeArgs, projectPath); } catch (mergeError: unknown) { // Check if this is a merge conflict const err = mergeError as { stdout?: string; stderr?: string; message?: string }; @@ -125,11 +136,10 @@ export async function performMerge( throw mergeError; } - // If squash merge, need to commit + // If squash merge, need to commit (using safe array-based command) if (options?.squash) { - await execAsync(`git commit -m "${options?.message || `Merge ${branchName} (squash)`}"`, { - cwd: projectPath, - }); + const squashMessage = options?.message || `Merge ${branchName} (squash)`; + await execGitCommand(['commit', '-m', squashMessage], projectPath); } // Optionally delete the worktree and branch after merging diff --git a/apps/server/src/services/pipeline-orchestrator.ts b/apps/server/src/services/pipeline-orchestrator.ts index 08be4092..ea2bf69e 100644 --- a/apps/server/src/services/pipeline-orchestrator.ts +++ b/apps/server/src/services/pipeline-orchestrator.ts @@ -460,6 +460,7 @@ export class PipelineOrchestrator { const session = this.testRunnerService.getSession(sessionId); if (session && session.status !== 'running' && session.status !== 'pending') { clearInterval(checkInterval); + clearTimeout(timeoutId); resolve({ status: session.status, exitCode: session.exitCode, @@ -469,7 +470,7 @@ export class PipelineOrchestrator { }); } }, 1000); - setTimeout(() => { + const timeoutId = setTimeout(() => { clearInterval(checkInterval); resolve({ status: 'failed', exitCode: null, duration: 600000 }); }, 600000); diff --git a/apps/server/src/services/plan-approval-service.ts b/apps/server/src/services/plan-approval-service.ts index 836d999f..3a677d49 100644 --- a/apps/server/src/services/plan-approval-service.ts +++ b/apps/server/src/services/plan-approval-service.ts @@ -83,6 +83,13 @@ export class PlanApprovalService { ); return new Promise((resolve, reject) => { + // Prevent duplicate registrations for the same key β€” reject and clean up existing entry + const existing = this.pendingApprovals.get(key); + if (existing) { + existing.reject(new Error('Superseded by a new waitForApproval call')); + this.pendingApprovals.delete(key); + } + // Set up timeout to prevent indefinite waiting and memory leaks // timeoutId stored in closure, NOT in PendingApproval object const timeoutId = setTimeout(() => { @@ -226,11 +233,11 @@ export class PlanApprovalService { status: approved ? 'approved' : 'rejected', approvedAt: approved ? new Date().toISOString() : undefined, reviewedByUser: true, - content: editedPlan, // Update content if user provided an edited version + ...(editedPlan !== undefined && { content: editedPlan }), // Only update content if user provided an edited version }); - // If rejected with feedback, emit event so client knows the rejection reason - if (!approved && feedback) { + // If rejected, emit event so client knows the rejection reason (even without feedback) + if (!approved) { this.eventBus.emitAutoModeEvent('plan_rejected', { featureId, projectPath, diff --git a/apps/ui/src/components/views/graph-view-page.tsx b/apps/ui/src/components/views/graph-view-page.tsx index 306b8eaa..3167647f 100644 --- a/apps/ui/src/components/views/graph-view-page.tsx +++ b/apps/ui/src/components/views/graph-view-page.tsx @@ -313,14 +313,18 @@ export function GraphViewPage() { // Handle add and start feature const handleAddAndStartFeature = useCallback( async (featureData: Parameters[0]) => { - const featuresBeforeIds = new Set(useAppStore.getState().features.map((f) => f.id)); - await handleAddFeature(featureData); + try { + const featuresBeforeIds = new Set(useAppStore.getState().features.map((f) => f.id)); + await handleAddFeature(featureData); - const latestFeatures = useAppStore.getState().features; - const newFeature = latestFeatures.find((f) => !featuresBeforeIds.has(f.id)); + const latestFeatures = useAppStore.getState().features; + const newFeature = latestFeatures.find((f) => !featuresBeforeIds.has(f.id)); - if (newFeature) { - await handleStartImplementation(newFeature); + if (newFeature) { + await handleStartImplementation(newFeature); + } + } catch (error) { + logger.error('Failed to add and start feature:', error); } }, [handleAddFeature, handleStartImplementation] From 434792a2eff8ad35145bb6f1d50165257e2ea8d9 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 16 Feb 2026 12:07:05 -0800 Subject: [PATCH 084/156] fix: Normalize 'main' branch to __main__ in auto-loop key generation --- apps/server/src/services/auto-loop-coordinator.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/server/src/services/auto-loop-coordinator.ts b/apps/server/src/services/auto-loop-coordinator.ts index 3e63cff1..ddc666d5 100644 --- a/apps/server/src/services/auto-loop-coordinator.ts +++ b/apps/server/src/services/auto-loop-coordinator.ts @@ -35,12 +35,12 @@ export interface ProjectAutoLoopState { * Generate a unique key for a worktree auto-loop instance. * * When branchName is null, this represents the main worktree (uses '__main__' sentinel). - * Named branches always use their exact name β€” the caller is responsible for passing - * null for the primary branch (main/master/etc.) so key matching stays consistent - * with ConcurrencyManager's dynamic primary branch resolution. + * The string 'main' is also normalized to '__main__' for consistency. + * Named branches always use their exact name. */ export function getWorktreeAutoLoopKey(projectPath: string, branchName: string | null): string { - return `${projectPath}::${branchName ?? '__main__'}`; + const normalizedBranch = branchName === 'main' ? null : branchName; + return `${projectPath}::${normalizedBranch ?? '__main__'}`; } export type ExecuteFeatureFn = ( From 0b03e70f1d54a9138eb0f2d64bd9688fdd3e883b Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 16 Feb 2026 12:27:56 -0800 Subject: [PATCH 085/156] fix: Resolve null coalescing, feature verification, and test abort handling issues --- apps/server/src/services/agent-executor.ts | 2 +- apps/server/src/services/auto-mode/facade.ts | 24 ++++-- apps/server/src/services/execution-service.ts | 1 + .../src/services/feature-state-manager.ts | 15 ++++ .../src/services/pipeline-orchestrator.ts | 73 ++++++++++++++----- .../src/services/plan-approval-service.ts | 36 ++++----- .../src/components/views/graph-view-page.tsx | 3 + 7 files changed, 109 insertions(+), 45 deletions(-) diff --git a/apps/server/src/services/agent-executor.ts b/apps/server/src/services/agent-executor.ts index dd1c179c..9a8772ef 100644 --- a/apps/server/src/services/agent-executor.ts +++ b/apps/server/src/services/agent-executor.ts @@ -333,7 +333,7 @@ export class AgentExecutor { userFeedback ); const taskStream = provider.executeQuery( - this.buildExecOpts(options, taskPrompt, Math.min(sdkOptions?.maxTurns || 100, 50)) + this.buildExecOpts(options, taskPrompt, Math.min(sdkOptions?.maxTurns ?? 50, 50)) ); let taskOutput = '', taskStartDetected = false, diff --git a/apps/server/src/services/auto-mode/facade.ts b/apps/server/src/services/auto-mode/facade.ts index af909d30..01985081 100644 --- a/apps/server/src/services/auto-mode/facade.ts +++ b/apps/server/src/services/auto-mode/facade.ts @@ -227,7 +227,7 @@ export class AutoModeServiceFacade { .replace(/\{\{taskName\}\}/g, task.description) .replace(/\{\{taskIndex\}\}/g, String(taskIndex + 1)) .replace(/\{\{totalTasks\}\}/g, String(allTasks.length)) - .replace(/\{\{taskDescription\}\}/g, task.description || task.description); + .replace(/\{\{taskDescription\}\}/g, task.description || task.name); if (feedback) { taskPrompt = taskPrompt.replace(/\{\{userFeedback\}\}/g, feedback); } @@ -636,15 +636,23 @@ Address the follow-up instructions above. Review the previous work and make the */ async verifyFeature(featureId: string): Promise { const feature = await this.featureStateManager.loadFeature(this.projectPath, featureId); - const sanitizedFeatureId = featureId.replace(/[^a-zA-Z0-9_-]/g, '-'); - const worktreePath = path.join(this.projectPath, '.worktrees', sanitizedFeatureId); let workDir = this.projectPath; - try { - await secureFs.access(worktreePath); - workDir = worktreePath; - } catch { - // No worktree + // Use worktreeResolver to find worktree path (consistent with commitFeature) + const branchName = feature?.branchName; + if (branchName) { + const resolved = await this.worktreeResolver.findWorktreeForBranch( + this.projectPath, + branchName + ); + if (resolved) { + try { + await secureFs.access(resolved); + workDir = resolved; + } catch { + // Fall back to project path + } + } } const verificationChecks = [ diff --git a/apps/server/src/services/execution-service.ts b/apps/server/src/services/execution-service.ts index 2af35fe7..75bb10bd 100644 --- a/apps/server/src/services/execution-service.ts +++ b/apps/server/src/services/execution-service.ts @@ -339,6 +339,7 @@ ${feature.spec} } catch (error) { const errorInfo = classifyError(error); if (errorInfo.isAbort) { + await this.updateFeatureStatusFn(projectPath, featureId, 'interrupted'); this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', { featureId, featureName: feature?.title, diff --git a/apps/server/src/services/feature-state-manager.ts b/apps/server/src/services/feature-state-manager.ts index c74ad88b..3fcf69fc 100644 --- a/apps/server/src/services/feature-state-manager.ts +++ b/apps/server/src/services/feature-state-manager.ts @@ -211,6 +211,8 @@ export class FeatureStateManager { */ async resetStuckFeatures(projectPath: string): Promise { const featuresDir = getFeaturesDir(projectPath); + let featuresScanned = 0; + let featuresReset = 0; try { const entries = await secureFs.readdir(featuresDir, { withFileTypes: true }); @@ -218,6 +220,7 @@ export class FeatureStateManager { for (const entry of entries) { if (!entry.isDirectory()) continue; + featuresScanned++; const featurePath = path.join(featuresDir, entry.name, 'feature.json'); const result = await readJsonWithRecovery(featurePath, null, { maxBackups: DEFAULT_BACKUP_COUNT, @@ -271,8 +274,13 @@ export class FeatureStateManager { if (needsUpdate) { feature.updatedAt = new Date().toISOString(); await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT }); + featuresReset++; } } + + logger.info( + `[resetStuckFeatures] Scanned ${featuresScanned} features, reset ${featuresReset} features for ${projectPath}` + ); } catch (error) { // If features directory doesn't exist, that's fine if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { @@ -334,6 +342,13 @@ export class FeatureStateManager { // PERSIST BEFORE EMIT await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT }); + + // Emit event for UI update + this.emitAutoModeEvent('plan_spec_updated', { + featureId, + projectPath, + planSpec: feature.planSpec, + }); } catch (error) { logger.error(`Failed to update planSpec for ${featureId}:`, error); } diff --git a/apps/server/src/services/pipeline-orchestrator.ts b/apps/server/src/services/pipeline-orchestrator.ts index ea2bf69e..3eb427c8 100644 --- a/apps/server/src/services/pipeline-orchestrator.ts +++ b/apps/server/src/services/pipeline-orchestrator.ts @@ -361,8 +361,14 @@ export class PipelineOrchestrator { await this.executePipeline(context); + // Re-fetch feature to check if executePipeline set a terminal status (e.g., merge_conflict) + const reloadedFeature = await this.featureLoader.getById(projectPath, featureId); const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified'; - await this.updateFeatureStatusFn(projectPath, featureId, finalStatus); + + // Only update status if not already in a terminal state + if (reloadedFeature && reloadedFeature.status !== 'merge_conflict') { + await this.updateFeatureStatusFn(projectPath, featureId, finalStatus); + } logger.info(`Pipeline resume completed for feature ${featureId}`); this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', { featureId, @@ -417,7 +423,10 @@ export class PipelineOrchestrator { message: testResult.error || 'Failed to start tests', }; - const completionResult = await this.waitForTestCompletion(testResult.result.sessionId); + const completionResult = await this.waitForTestCompletion( + testResult.result.sessionId, + abortController.signal + ); if (completionResult.status === 'passed') return { success: true, testsPassed: true }; const sessionOutput = this.testRunnerService.getSessionOutput(testResult.result.sessionId); @@ -453,10 +462,19 @@ export class PipelineOrchestrator { /** Wait for test completion */ private async waitForTestCompletion( - sessionId: string + sessionId: string, + signal: AbortSignal ): Promise<{ status: TestRunStatus; exitCode: number | null; duration: number }> { return new Promise((resolve) => { const checkInterval = setInterval(() => { + // Check for abort + if (signal.aborted) { + clearInterval(checkInterval); + clearTimeout(timeoutId); + resolve({ status: 'failed', exitCode: null, duration: 0 }); + return; + } + const session = this.testRunnerService.getSession(sessionId); if (session && session.status !== 'running' && session.status !== 'pending') { clearInterval(checkInterval); @@ -471,6 +489,12 @@ export class PipelineOrchestrator { } }, 1000); const timeoutId = setTimeout(() => { + // Check for abort before timeout resolution + if (signal.aborted) { + clearInterval(checkInterval); + resolve({ status: 'failed', exitCode: null, duration: 0 }); + return; + } clearInterval(checkInterval); resolve({ status: 'failed', exitCode: null, duration: 600000 }); }, 600000); @@ -484,12 +508,15 @@ export class PipelineOrchestrator { logger.info(`Attempting auto-merge for feature ${featureId} (branch: ${branchName})`); try { + // Get the primary branch dynamically instead of hardcoding 'main' + const targetBranch = await this.worktreeResolver.getCurrentBranch(projectPath); + // Call merge service directly instead of HTTP fetch const result = await performMerge( projectPath, branchName, worktreePath || projectPath, - 'main', + targetBranch, { deleteWorktreeAndBranch: false, } @@ -524,12 +551,16 @@ export class PipelineOrchestrator { } } - /** Build a concise test failure summary for the agent */ - buildTestFailureSummary(scrollback: string): string { + /** Shared helper to parse test output lines and extract failure information */ + private parseTestLines(scrollback: string): { + failedTests: string[]; + passCount: number; + failCount: number; + } { const lines = scrollback.split('\n'); const failedTests: string[] = []; - let passCount = 0, - failCount = 0; + let passCount = 0; + let failCount = 0; for (const line of lines) { const trimmed = line.trim(); @@ -537,30 +568,34 @@ export class PipelineOrchestrator { const match = trimmed.match(/(?:FAIL|FAILED)\s+(.+)/); if (match) failedTests.push(match[1].trim()); failCount++; - } else if (trimmed.includes('PASS') || trimmed.includes('PASSED')) passCount++; - if (trimmed.match(/^>\s+.*\.(test|spec)\./)) failedTests.push(trimmed.replace(/^>\s+/, '')); + } else if (trimmed.includes('PASS') || trimmed.includes('PASSED')) { + passCount++; + } + if (trimmed.match(/^>\s+.*\.(test|spec)\./)) { + failedTests.push(trimmed.replace(/^>\s+/, '')); + } if ( trimmed.includes('AssertionError') || trimmed.includes('toBe') || trimmed.includes('toEqual') - ) + ) { failedTests.push(trimmed); + } } + return { failedTests, passCount, failCount }; + } + + /** Build a concise test failure summary for the agent */ + buildTestFailureSummary(scrollback: string): string { + const { failedTests, passCount, failCount } = this.parseTestLines(scrollback); const unique = [...new Set(failedTests)].slice(0, 10); return `Test Results: ${passCount} passed, ${failCount} failed.\n\nFailed tests:\n${unique.map((t) => `- ${t}`).join('\n')}\n\nOutput (last 2000 chars):\n${scrollback.slice(-2000)}`; } /** Extract failed test names from scrollback */ private extractFailedTestNames(scrollback: string): string[] { - const failedTests: string[] = []; - for (const line of scrollback.split('\n')) { - const trimmed = line.trim(); - if (trimmed.includes('FAIL') || trimmed.includes('FAILED')) { - const match = trimmed.match(/(?:FAIL|FAILED)\s+(.+)/); - if (match) failedTests.push(match[1].trim()); - } - } + const { failedTests } = this.parseTestLines(scrollback); return [...new Set(failedTests)].slice(0, 20); } } diff --git a/apps/server/src/services/plan-approval-service.ts b/apps/server/src/services/plan-approval-service.ts index 3a677d49..ebd37767 100644 --- a/apps/server/src/services/plan-approval-service.ts +++ b/apps/server/src/services/plan-approval-service.ts @@ -90,25 +90,10 @@ export class PlanApprovalService { this.pendingApprovals.delete(key); } - // Set up timeout to prevent indefinite waiting and memory leaks - // timeoutId stored in closure, NOT in PendingApproval object - const timeoutId = setTimeout(() => { - const pending = this.pendingApprovals.get(key); - if (pending) { - logger.warn( - `Plan approval for feature ${featureId} timed out after ${timeoutMinutes} minutes` - ); - this.pendingApprovals.delete(key); - reject( - new Error( - `Plan approval timed out after ${timeoutMinutes} minutes - feature execution cancelled` - ) - ); - } - }, timeoutMs); - // Wrap resolve/reject to clear timeout when approval is resolved // This ensures timeout is ALWAYS cleared on any resolution path + // Define wrappers BEFORE setTimeout so they can be used in timeout callback + let timeoutId: NodeJS.Timeout; const wrappedResolve = (result: PlanApprovalResult) => { clearTimeout(timeoutId); resolve(result); @@ -119,6 +104,23 @@ export class PlanApprovalService { reject(error); }; + // Set up timeout to prevent indefinite waiting and memory leaks + // Now timeoutId assignment happens after wrappers are defined + timeoutId = setTimeout(() => { + const pending = this.pendingApprovals.get(key); + if (pending) { + logger.warn( + `Plan approval for feature ${featureId} timed out after ${timeoutMinutes} minutes` + ); + this.pendingApprovals.delete(key); + wrappedReject( + new Error( + `Plan approval timed out after ${timeoutMinutes} minutes - feature execution cancelled` + ) + ); + } + }, timeoutMs); + this.pendingApprovals.set(key, { resolve: wrappedResolve, reject: wrappedReject, diff --git a/apps/ui/src/components/views/graph-view-page.tsx b/apps/ui/src/components/views/graph-view-page.tsx index 3167647f..dc32b6c5 100644 --- a/apps/ui/src/components/views/graph-view-page.tsx +++ b/apps/ui/src/components/views/graph-view-page.tsx @@ -325,6 +325,9 @@ export function GraphViewPage() { } } catch (error) { logger.error('Failed to add and start feature:', error); + toast.error( + `Failed to add and start feature: ${error instanceof Error ? error.message : String(error)}` + ); } }, [handleAddFeature, handleStartImplementation] From ab5d6a0e54636433b8e673aaa4c921cdc46bb524 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 16 Feb 2026 13:14:55 -0800 Subject: [PATCH 086/156] feat: Improve callback safety and remove unnecessary formatting in auto-mode facade --- apps/server/src/services/agent-executor.ts | 6 +- apps/server/src/services/auto-mode/facade.ts | 101 ++++++------------ .../src/services/feature-state-manager.ts | 46 ++++---- .../src/services/pipeline-orchestrator.ts | 23 ++-- apps/ui/eslint.config.mjs | 1 + apps/ui/src/store/app-store.ts | 17 +-- apps/ui/src/store/types/state-types.ts | 4 +- 7 files changed, 80 insertions(+), 118 deletions(-) diff --git a/apps/server/src/services/agent-executor.ts b/apps/server/src/services/agent-executor.ts index 9a8772ef..0d9c2399 100644 --- a/apps/server/src/services/agent-executor.ts +++ b/apps/server/src/services/agent-executor.ts @@ -126,9 +126,7 @@ export class AgentExecutor { const appendRawEvent = (event: unknown): void => { if (!enableRawOutput) return; try { - rawOutputLines.push( - JSON.stringify({ timestamp: new Date().toISOString(), event }, null, 4) - ); + rawOutputLines.push(JSON.stringify({ timestamp: new Date().toISOString(), event })); if (rawWriteTimeout) clearTimeout(rawWriteTimeout); rawWriteTimeout = setTimeout(async () => { try { @@ -552,7 +550,7 @@ export class AgentExecutor { }); let revText = ''; for await (const msg of provider.executeQuery( - this.buildExecOpts(options, revPrompt, sdkOptions?.maxTurns || 100) + this.buildExecOpts(options, revPrompt, sdkOptions?.maxTurns ?? 100) )) { if (msg.type === 'assistant' && msg.message?.content) for (const b of msg.message.content) diff --git a/apps/server/src/services/auto-mode/facade.ts b/apps/server/src/services/auto-mode/facade.ts index 01985081..5d8bde58 100644 --- a/apps/server/src/services/auto-mode/facade.ts +++ b/apps/server/src/services/auto-mode/facade.ts @@ -143,9 +143,20 @@ export class AutoModeServiceFacade { return prompt; }; - // Create placeholder callbacks - will be bound to facade methods after creation - // These use closures to capture the facade instance once created + // Create placeholder callbacks - will be bound to facade methods after creation. + // These use closures to capture the facade instance once created. + // INVARIANT: All callbacks passed to PipelineOrchestrator, AutoLoopCoordinator, + // and ExecutionService are invoked asynchronously (never during construction), + // so facadeInstance is guaranteed to be assigned before any callback runs. let facadeInstance: AutoModeServiceFacade | null = null; + const getFacade = (): AutoModeServiceFacade => { + if (!facadeInstance) { + throw new Error( + 'AutoModeServiceFacade not yet initialized β€” callback invoked during construction' + ); + } + return facadeInstance; + }; // PipelineOrchestrator - runAgentFn is a stub; routes use AutoModeService directly const pipelineOrchestrator = new PipelineOrchestrator( @@ -162,7 +173,7 @@ export class AutoModeServiceFacade { loadContextFiles, buildFeaturePrompt, (pPath, featureId, useWorktrees, _isAutoMode, _model, opts) => - facadeInstance!.executeFeature(featureId, useWorktrees, false, undefined, opts), + getFacade().executeFeature(featureId, useWorktrees, false, undefined, opts), // runAgentFn - delegates to AgentExecutor async ( workDir: string, @@ -227,7 +238,7 @@ export class AutoModeServiceFacade { .replace(/\{\{taskName\}\}/g, task.description) .replace(/\{\{taskIndex\}\}/g, String(taskIndex + 1)) .replace(/\{\{totalTasks\}\}/g, String(allTasks.length)) - .replace(/\{\{taskDescription\}\}/g, task.description || task.name); + .replace(/\{\{taskDescription\}\}/g, task.description || `Task ${task.id}`); if (feedback) { taskPrompt = taskPrompt.replace(/\{\{userFeedback\}\}/g, feedback); } @@ -248,7 +259,7 @@ export class AutoModeServiceFacade { settingsService, // Callbacks (pPath, featureId, useWorktrees, isAutoMode) => - facadeInstance!.executeFeature(featureId, useWorktrees, isAutoMode), + getFacade().executeFeature(featureId, useWorktrees, isAutoMode), async (pPath, branchName) => { const features = await featureLoader.getAll(pPath); // For main worktree (branchName === null), resolve the actual primary branch name @@ -266,8 +277,8 @@ export class AutoModeServiceFacade { ); }, (pPath, branchName, maxConcurrency) => - facadeInstance!.saveExecutionStateForProject(branchName, maxConcurrency), - (pPath, branchName) => facadeInstance!.clearExecutionState(branchName), + getFacade().saveExecutionStateForProject(branchName, maxConcurrency), + (pPath, branchName) => getFacade().clearExecutionState(branchName), (pPath) => featureStateManager.resetStuckFeatures(pPath), (feature) => feature.status === 'completed' || @@ -375,16 +386,16 @@ export class AutoModeServiceFacade { async () => { /* recordLearnings - stub */ }, - (pPath, featureId) => facadeInstance!.contextExists(featureId), + (pPath, featureId) => getFacade().contextExists(featureId), (pPath, featureId, useWorktrees, _calledInternally) => - facadeInstance!.resumeFeature(featureId, useWorktrees, _calledInternally), + getFacade().resumeFeature(featureId, useWorktrees, _calledInternally), (errorInfo) => autoLoopCoordinator.trackFailureAndCheckPauseForProject(projectPath, null, errorInfo), (errorInfo) => autoLoopCoordinator.signalShouldPauseForProject(projectPath, null, errorInfo), () => { /* recordSuccess - no-op */ }, - (_pPath) => facadeInstance!.saveExecutionState(), + (_pPath) => getFacade().saveExecutionState(), loadContextFiles ); @@ -395,13 +406,7 @@ export class AutoModeServiceFacade { settingsService, // Callbacks (pPath, featureId, useWorktrees, isAutoMode, providedWorktreePath, opts) => - facadeInstance!.executeFeature( - featureId, - useWorktrees, - isAutoMode, - providedWorktreePath, - opts - ), + getFacade().executeFeature(featureId, useWorktrees, isAutoMode, providedWorktreePath, opts), (pPath, featureId) => featureStateManager.loadFeature(pPath, featureId), (pPath, featureId, status) => pipelineOrchestrator.detectPipelineStatus(pPath, featureId, status), @@ -547,7 +552,9 @@ export class AutoModeServiceFacade { imagePaths?: string[], useWorktrees = true ): Promise { - // This method contains substantial logic - delegates most work to AgentExecutor + // Stub: acquire concurrency slot then immediately throw. + // Heavy I/O (loadFeature, worktree resolution, context reading, prompt building) + // is deferred to the real AutoModeService.followUpFeature implementation. validateWorkingDirectory(this.projectPath); const runningEntry = this.concurrencyManager.acquire({ @@ -555,56 +562,6 @@ export class AutoModeServiceFacade { projectPath: this.projectPath, isAutoMode: false, }); - const abortController = runningEntry.abortController; - - const feature = await this.featureStateManager.loadFeature(this.projectPath, featureId); - let workDir = path.resolve(this.projectPath); - let worktreePath: string | null = null; - const branchName = feature?.branchName || `feature/${featureId}`; - - if (useWorktrees && branchName) { - worktreePath = await this.worktreeResolver.findWorktreeForBranch( - this.projectPath, - branchName - ); - if (worktreePath) { - workDir = worktreePath; - } - } - - // Load previous context - const featureDir = getFeatureDir(this.projectPath, featureId); - const contextPath = path.join(featureDir, 'agent-output.md'); - let previousContext = ''; - try { - previousContext = (await secureFs.readFile(contextPath, 'utf-8')) as string; - } catch { - // No previous context - } - - const prompts = await getPromptCustomization(this.settingsService, '[Facade]'); - - // Build follow-up prompt inline (no template in TaskExecutionPrompts) - let fullPrompt = `## Follow-up on Feature Implementation - -${feature ? `**Feature ID:** ${feature.id}\n**Title:** ${feature.title || 'Untitled'}\n**Description:** ${feature.description}` : `**Feature ID:** ${featureId}`} -`; - - if (previousContext) { - fullPrompt += ` -## Previous Agent Work -The following is the output from the previous implementation attempt: - -${previousContext} -`; - } - - fullPrompt += ` -## Follow-up Instructions -${prompt} - -## Task -Address the follow-up instructions above. Review the previous work and make the requested changes or fixes.`; try { // NOTE: Facade does not have runAgent - this method requires AutoModeService @@ -617,8 +574,8 @@ Address the follow-up instructions above. Review the previous work and make the if (!errorInfo.isAbort) { this.eventBus.emitAutoModeEvent('auto_mode_error', { featureId, - featureName: feature?.title, - branchName: feature?.branchName ?? null, + featureName: undefined, + branchName: null, error: errorInfo.message, errorType: errorInfo.type, projectPath: this.projectPath, @@ -854,7 +811,9 @@ Address the follow-up instructions above. Review the previous work and make the async checkWorktreeCapacity(featureId: string): Promise { const feature = await this.featureStateManager.loadFeature(this.projectPath, featureId); const rawBranchName = feature?.branchName ?? null; - const branchName = rawBranchName === 'main' ? null : rawBranchName; + // Normalize primary branch to null (works for main, master, or any default branch) + const primaryBranch = await this.worktreeResolver.getCurrentBranch(this.projectPath); + const branchName = rawBranchName === primaryBranch ? null : rawBranchName; const maxAgents = await this.autoLoopCoordinator.resolveMaxConcurrency( this.projectPath, diff --git a/apps/server/src/services/feature-state-manager.ts b/apps/server/src/services/feature-state-manager.ts index 3fcf69fc..cd35859e 100644 --- a/apps/server/src/services/feature-state-manager.ts +++ b/apps/server/src/services/feature-state-manager.ts @@ -123,23 +123,28 @@ export class FeatureStateManager { }); // Create notifications for important status changes - const notificationService = getNotificationService(); - if (status === 'waiting_approval') { - await notificationService.createNotification({ - type: 'feature_waiting_approval', - title: 'Feature Ready for Review', - message: `"${feature.name || featureId}" is ready for your review and approval.`, - featureId, - projectPath, - }); - } else if (status === 'verified') { - await notificationService.createNotification({ - type: 'feature_verified', - title: 'Feature Verified', - message: `"${feature.name || featureId}" has been verified and is complete.`, - featureId, - projectPath, - }); + // Wrapped in try-catch so failures don't block syncFeatureToAppSpec below + try { + const notificationService = getNotificationService(); + if (status === 'waiting_approval') { + await notificationService.createNotification({ + type: 'feature_waiting_approval', + title: 'Feature Ready for Review', + message: `"${feature.name || featureId}" is ready for your review and approval.`, + featureId, + projectPath, + }); + } else if (status === 'verified') { + await notificationService.createNotification({ + type: 'feature_verified', + title: 'Feature Verified', + message: `"${feature.name || featureId}" has been verified and is complete.`, + featureId, + projectPath, + }); + } + } catch (notificationError) { + logger.warn(`Failed to create notification for feature ${featureId}:`, notificationError); } // Sync completed/verified features to app_spec.txt @@ -334,7 +339,7 @@ export class FeatureStateManager { Object.assign(feature.planSpec, updates); // If content is being updated and it's different from old content, increment version - if (updates.content && updates.content !== oldContent) { + if (updates.content !== undefined && updates.content !== oldContent) { feature.planSpec.version = (feature.planSpec.version || 0) + 1; } @@ -446,6 +451,11 @@ export class FeatureStateManager { status, tasks: feature.planSpec.tasks, }); + } else { + const availableIds = feature.planSpec.tasks.map((t) => t.id).join(', '); + logger.warn( + `[updateTaskStatus] Task ${taskId} not found in feature ${featureId} (${projectPath}). Available task IDs: [${availableIds}]` + ); } } catch (error) { logger.error(`Failed to update task ${taskId} status for ${featureId}:`, error); diff --git a/apps/server/src/services/pipeline-orchestrator.ts b/apps/server/src/services/pipeline-orchestrator.ts index 3eb427c8..4308825b 100644 --- a/apps/server/src/services/pipeline-orchestrator.ts +++ b/apps/server/src/services/pipeline-orchestrator.ts @@ -362,7 +362,7 @@ export class PipelineOrchestrator { await this.executePipeline(context); // Re-fetch feature to check if executePipeline set a terminal status (e.g., merge_conflict) - const reloadedFeature = await this.featureLoader.getById(projectPath, featureId); + const reloadedFeature = await this.featureStateManager.loadFeature(projectPath, featureId); const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified'; // Only update status if not already in a terminal state @@ -516,7 +516,7 @@ export class PipelineOrchestrator { projectPath, branchName, worktreePath || projectPath, - targetBranch, + targetBranch || 'main', { deleteWorktreeAndBranch: false, } @@ -562,22 +562,33 @@ export class PipelineOrchestrator { let passCount = 0; let failCount = 0; + let inFailureContext = false; for (const line of lines) { const trimmed = line.trim(); if (trimmed.includes('FAIL') || trimmed.includes('FAILED')) { const match = trimmed.match(/(?:FAIL|FAILED)\s+(.+)/); if (match) failedTests.push(match[1].trim()); failCount++; + inFailureContext = true; } else if (trimmed.includes('PASS') || trimmed.includes('PASSED')) { passCount++; + inFailureContext = false; } if (trimmed.match(/^>\s+.*\.(test|spec)\./)) { failedTests.push(trimmed.replace(/^>\s+/, '')); } - if ( - trimmed.includes('AssertionError') || - trimmed.includes('toBe') || - trimmed.includes('toEqual') + // Only capture assertion details when they appear in failure context + // or match explicit assertion error / expect patterns + if (trimmed.includes('AssertionError') || trimmed.includes('AssertionError')) { + failedTests.push(trimmed); + } else if ( + inFailureContext && + /expect\(.+\)\.(toBe|toEqual|toMatch|toThrow|toContain)\s*\(/.test(trimmed) + ) { + failedTests.push(trimmed); + } else if ( + inFailureContext && + (trimmed.startsWith('Expected') || trimmed.startsWith('Received')) ) { failedTests.push(trimmed); } diff --git a/apps/ui/eslint.config.mjs b/apps/ui/eslint.config.mjs index 6cf025de..3ad4d79d 100644 --- a/apps/ui/eslint.config.mjs +++ b/apps/ui/eslint.config.mjs @@ -96,6 +96,7 @@ const eslintConfig = defineConfig([ setInterval: 'readonly', clearTimeout: 'readonly', clearInterval: 'readonly', + queueMicrotask: 'readonly', // Node.js (for scripts and Electron) process: 'readonly', require: 'readonly', diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index c0735355..7cc77907 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -7,7 +7,6 @@ import { createLogger } from '@automaker/utils/logger'; // Note: setItem/getItem moved to ./utils/theme-utils.ts import { UI_SANS_FONT_OPTIONS, UI_MONO_FONT_OPTIONS } from '@/config/ui-font-options'; import type { - Feature as BaseFeature, FeatureImagePath, FeatureTextFilePath, ModelAlias, @@ -15,25 +14,11 @@ import type { ThinkingLevel, ReasoningEffort, ModelProvider, - CursorModelId, - CodexModelId, - OpencodeModelId, - GeminiModelId, - CopilotModelId, - PhaseModelConfig, PhaseModelKey, PhaseModelEntry, - MCPServerConfig, - FeatureStatusWithPipeline, - PipelineConfig, PipelineStep, - PromptCustomization, ModelDefinition, ServerLogLevel, - EventHook, - ClaudeApiProfile, - ClaudeCompatibleProvider, - SidebarStyle, ParsedTask, PlanSpec, } from '@automaker/types'; @@ -2131,7 +2116,7 @@ export const useAppStore = create()((set, get) => ({ const updateSizes = (layout: TerminalPanelContent): TerminalPanelContent => { if (layout.type === 'split') { // Find matching panels and update sizes - const updatedPanels = layout.panels.map((panel, index) => { + const updatedPanels = layout.panels.map((panel, _index) => { // Generate key for this panel const panelKey = panel.type === 'split' diff --git a/apps/ui/src/store/types/state-types.ts b/apps/ui/src/store/types/state-types.ts index 4febb1ca..e06bb618 100644 --- a/apps/ui/src/store/types/state-types.ts +++ b/apps/ui/src/store/types/state-types.ts @@ -2,8 +2,6 @@ import type { Project, TrashedProject } from '@/lib/electron'; import type { ModelAlias, PlanningMode, - ThinkingLevel, - ReasoningEffort, ModelProvider, CursorModelId, CodexModelId, @@ -33,7 +31,7 @@ import type { BackgroundSettings, } from './ui-types'; import type { ApiKeys } from './settings-types'; -import type { ChatMessage, ChatSession, FeatureImage } from './chat-types'; +import type { ChatMessage, ChatSession } from './chat-types'; import type { TerminalState, TerminalPanelContent, PersistedTerminalState } from './terminal-types'; import type { Feature, ProjectAnalysis } from './project-types'; import type { ClaudeUsage, CodexUsage } from './usage-types'; From 67dd6281158c6dd11045d7f5680076156b20b5b3 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 16 Feb 2026 13:35:49 -0800 Subject: [PATCH 087/156] test: Add mock for getCurrentBranch in pipeline orchestrator tests --- apps/server/tests/unit/services/pipeline-orchestrator.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/server/tests/unit/services/pipeline-orchestrator.test.ts b/apps/server/tests/unit/services/pipeline-orchestrator.test.ts index 44bf8d9a..aa543afb 100644 --- a/apps/server/tests/unit/services/pipeline-orchestrator.test.ts +++ b/apps/server/tests/unit/services/pipeline-orchestrator.test.ts @@ -165,6 +165,7 @@ describe('PipelineOrchestrator', () => { mockWorktreeResolver = { findWorktreeForBranch: vi.fn().mockResolvedValue('/test/worktree'), + getCurrentBranch: vi.fn().mockResolvedValue('main'), } as unknown as WorktreeResolver; mockConcurrencyManager = { From 727a7a5b9db12bad8ad6484c2432bcb36db98518 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 16 Feb 2026 14:14:17 -0800 Subject: [PATCH 088/156] feat: Exclude waiting_approval cards from active running state display --- .../components/kanban-card/kanban-card.tsx | 15 +++++++++------ .../board-view/components/list-view/list-row.tsx | 8 ++++++-- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx index f6725a7d..59bf8d8e 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx @@ -108,6 +108,9 @@ export const KanbanCard = memo(function KanbanCard({ currentProject: state.currentProject, })) ); + // A card in waiting_approval should not display as "actively running" even if + // it's still in the runningAutoTasks list. The waiting_approval UI takes precedence. + const isActivelyRunning = !!isCurrentAutoTask && feature.status !== 'waiting_approval'; const [isLifted, setIsLifted] = useState(false); useLayoutEffect(() => { @@ -186,10 +189,10 @@ export const KanbanCard = memo(function KanbanCard({ // Disable hover translate for in-progress cards to prevent gap showing gradient isInteractive && !reduceEffects && - !isCurrentAutoTask && + !isActivelyRunning && 'hover:-translate-y-0.5 hover:shadow-md hover:shadow-black/10 bg-transparent', !glassmorphism && 'backdrop-blur-[0px]!', - !isCurrentAutoTask && + !isActivelyRunning && cardBorderEnabled && (cardBorderOpacity === 100 ? 'border-border/50' : 'border'), hasError && 'border-[var(--status-error)] border-2 shadow-[var(--status-error-bg)] shadow-lg', @@ -206,7 +209,7 @@ export const KanbanCard = memo(function KanbanCard({ const renderCardContent = () => ( - {isCurrentAutoTask ? ( + {isActivelyRunning ? (
{renderCardContent()}
) : ( renderCardContent() diff --git a/apps/ui/src/components/views/board-view/components/list-view/list-row.tsx b/apps/ui/src/components/views/board-view/components/list-view/list-row.tsx index 2c5474f9..6d14c269 100644 --- a/apps/ui/src/components/views/board-view/components/list-view/list-row.tsx +++ b/apps/ui/src/components/views/board-view/components/list-view/list-row.tsx @@ -209,6 +209,10 @@ export const ListRow = memo(function ListRow({ blockingDependencies = [], className, }: ListRowProps) { + // A card in waiting_approval should not display as "actively running" even if + // it's still in the runningAutoTasks list. The waiting_approval UI takes precedence. + const isActivelyRunning = isCurrentAutoTask && feature.status !== 'waiting_approval'; + const handleRowClick = useCallback( (e: React.MouseEvent) => { // Don't trigger row click if clicking on checkbox or actions @@ -349,13 +353,13 @@ export const ListRow = memo(function ListRow({ {/* Actions column */}
- +
); // Wrap with animated border for currently running auto task - if (isCurrentAutoTask) { + if (isActivelyRunning) { return
{rowContent}
; } From 416ef3a3940c7cdd4d8bcc4c9ad80e8313bd1b12 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 16 Feb 2026 18:58:42 -0800 Subject: [PATCH 089/156] feat: Add error handling to auto-mode facade and implement followUp feature. Fix Claude weekly usage indicator. Fix mobile card drag --- apps/server/src/services/auto-mode/compat.ts | 5 +- apps/server/src/services/auto-mode/facade.ts | 155 ++++++++++++++---- apps/server/src/services/auto-mode/index.ts | 1 + apps/server/src/services/auto-mode/types.ts | 20 +++ .../src/services/claude-usage-service.ts | 37 ++++- .../src/services/feature-state-manager.ts | 41 +++++ .../services/claude-usage-service.test.ts | 60 +++++++ .../services/feature-state-manager.test.ts | 90 ++++++++++ .../src/components/ui/task-progress-panel.tsx | 55 ++++--- apps/ui/src/components/usage-popover.tsx | 51 +++++- .../kanban-card/agent-info-panel.tsx | 12 ++ .../components/kanban-card/card-header.tsx | 9 +- .../components/kanban-card/kanban-card.tsx | 8 +- .../views/board-view/mobile-usage-bar.tsx | 25 ++- apps/ui/src/store/utils/usage-utils.ts | 59 +++++++ 15 files changed, 552 insertions(+), 76 deletions(-) diff --git a/apps/server/src/services/auto-mode/compat.ts b/apps/server/src/services/auto-mode/compat.ts index cece2475..2c713c01 100644 --- a/apps/server/src/services/auto-mode/compat.ts +++ b/apps/server/src/services/auto-mode/compat.ts @@ -13,6 +13,7 @@ import { GlobalAutoModeService } from './global-service.js'; import { AutoModeServiceFacade } from './facade.js'; import type { SettingsService } from '../settings-service.js'; import type { FeatureLoader } from '../feature-loader.js'; +import type { ClaudeUsageService } from '../claude-usage-service.js'; import type { FacadeOptions, AutoModeStatus, RunningAgentInfo } from './types.js'; /** @@ -27,7 +28,8 @@ export class AutoModeServiceCompat { constructor( events: EventEmitter, settingsService: SettingsService | null, - featureLoader: FeatureLoader + featureLoader: FeatureLoader, + claudeUsageService?: ClaudeUsageService | null ) { this.globalService = new GlobalAutoModeService(events, settingsService, featureLoader); const sharedServices = this.globalService.getSharedServices(); @@ -37,6 +39,7 @@ export class AutoModeServiceCompat { settingsService, featureLoader, sharedServices, + claudeUsageService: claudeUsageService ?? null, }; } diff --git a/apps/server/src/services/auto-mode/facade.ts b/apps/server/src/services/auto-mode/facade.ts index 5d8bde58..e31543b4 100644 --- a/apps/server/src/services/auto-mode/facade.ts +++ b/apps/server/src/services/auto-mode/facade.ts @@ -38,6 +38,7 @@ import type { SettingsService } from '../settings-service.js'; import type { EventEmitter } from '../../lib/events.js'; import type { FacadeOptions, + FacadeError, AutoModeStatus, ProjectAutoModeStatus, WorktreeCapacityInfo, @@ -89,6 +90,45 @@ export class AutoModeServiceFacade { private readonly settingsService: SettingsService | null ) {} + /** + * Classify and log an error at the facade boundary. + * Emits an error event to the UI so failures are surfaced to the user. + * + * @param error - The caught error + * @param method - The facade method name where the error occurred + * @param featureId - Optional feature ID for context + * @returns The classified FacadeError for structured consumption + */ + private handleFacadeError(error: unknown, method: string, featureId?: string): FacadeError { + const errorInfo = classifyError(error); + + // Log at the facade boundary for debugging + logger.error( + `[${method}] ${featureId ? `Feature ${featureId}: ` : ''}${errorInfo.message}`, + error + ); + + // Emit error event to UI unless it's an abort/cancellation + if (!errorInfo.isAbort && !errorInfo.isCancellation) { + this.eventBus.emitAutoModeEvent('auto_mode_error', { + featureId: featureId ?? null, + featureName: undefined, + branchName: null, + error: errorInfo.message, + errorType: errorInfo.type, + projectPath: this.projectPath, + }); + } + + return { + method, + errorType: errorInfo.type, + message: errorInfo.message, + featureId, + projectPath: this.projectPath, + }; + } + /** * Create a new AutoModeServiceFacade instance for a specific project. * @@ -447,11 +487,16 @@ export class AutoModeServiceFacade { * @param maxConcurrency - Maximum concurrent features */ async startAutoLoop(branchName: string | null = null, maxConcurrency?: number): Promise { - return this.autoLoopCoordinator.startAutoLoopForProject( - this.projectPath, - branchName, - maxConcurrency - ); + try { + return await this.autoLoopCoordinator.startAutoLoopForProject( + this.projectPath, + branchName, + maxConcurrency + ); + } catch (error) { + this.handleFacadeError(error, 'startAutoLoop'); + throw error; + } } /** @@ -459,7 +504,12 @@ export class AutoModeServiceFacade { * @param branchName - The branch name, or null for main worktree */ async stopAutoLoop(branchName: string | null = null): Promise { - return this.autoLoopCoordinator.stopAutoLoopForProject(this.projectPath, branchName); + try { + return await this.autoLoopCoordinator.stopAutoLoopForProject(this.projectPath, branchName); + } catch (error) { + this.handleFacadeError(error, 'stopAutoLoop'); + throw error; + } } /** @@ -500,14 +550,19 @@ export class AutoModeServiceFacade { _calledInternally?: boolean; } ): Promise { - return this.executionService.executeFeature( - this.projectPath, - featureId, - useWorktrees, - isAutoMode, - providedWorktreePath, - options - ); + try { + return await this.executionService.executeFeature( + this.projectPath, + featureId, + useWorktrees, + isAutoMode, + providedWorktreePath, + options + ); + } catch (error) { + this.handleFacadeError(error, 'executeFeature', featureId); + throw error; + } } /** @@ -515,9 +570,14 @@ export class AutoModeServiceFacade { * @param featureId - ID of the feature to stop */ async stopFeature(featureId: string): Promise { - // Cancel any pending plan approval for this feature - this.cancelPlanApproval(featureId); - return this.executionService.stopFeature(featureId); + try { + // Cancel any pending plan approval for this feature + this.cancelPlanApproval(featureId); + return await this.executionService.stopFeature(featureId); + } catch (error) { + this.handleFacadeError(error, 'stopFeature', featureId); + throw error; + } } /** @@ -552,23 +612,54 @@ export class AutoModeServiceFacade { imagePaths?: string[], useWorktrees = true ): Promise { - // Stub: acquire concurrency slot then immediately throw. - // Heavy I/O (loadFeature, worktree resolution, context reading, prompt building) - // is deferred to the real AutoModeService.followUpFeature implementation. validateWorkingDirectory(this.projectPath); - const runningEntry = this.concurrencyManager.acquire({ - featureId, - projectPath: this.projectPath, - isAutoMode: false, - }); - try { - // NOTE: Facade does not have runAgent - this method requires AutoModeService - // Do NOT emit start events before throwing to prevent false start events - throw new Error( - 'followUpFeature not fully implemented in facade - use AutoModeService.followUpFeature instead' - ); + // Load feature to build the prompt context + const feature = await this.featureStateManager.loadFeature(this.projectPath, featureId); + if (!feature) throw new Error(`Feature ${featureId} not found`); + + // Read previous agent output as context + const featureDir = getFeatureDir(this.projectPath, featureId); + let previousContext = ''; + try { + previousContext = (await secureFs.readFile( + path.join(featureDir, 'agent-output.md'), + 'utf-8' + )) as string; + } catch { + // No previous context available - that's OK + } + + // Build the feature prompt section + const featurePrompt = `## Feature Implementation Task\n\n**Feature ID:** ${feature.id}\n**Title:** ${feature.title || 'Untitled Feature'}\n**Description:** ${feature.description}\n`; + + // Get the follow-up prompt template and build the continuation prompt + const prompts = await getPromptCustomization(this.settingsService, '[Facade]'); + let continuationPrompt = prompts.autoMode.followUpPromptTemplate; + continuationPrompt = continuationPrompt + .replace(/\{\{featurePrompt\}\}/g, featurePrompt) + .replace(/\{\{previousContext\}\}/g, previousContext) + .replace(/\{\{followUpInstructions\}\}/g, prompt); + + // Store image paths on the feature so executeFeature can pick them up + if (imagePaths && imagePaths.length > 0) { + feature.imagePaths = imagePaths.map((p) => ({ + path: p, + filename: p.split('/').pop() || p, + mimeType: 'image/*', + })); + await this.featureStateManager.updateFeatureStatus( + this.projectPath, + featureId, + feature.status || 'in_progress' + ); + } + + // Delegate to executeFeature with the built continuation prompt + await this.executeFeature(featureId, useWorktrees, false, undefined, { + continuationPrompt, + }); } catch (error) { const errorInfo = classifyError(error); if (!errorInfo.isAbort) { @@ -582,8 +673,6 @@ export class AutoModeServiceFacade { }); } throw error; - } finally { - this.concurrencyManager.release(featureId); } } diff --git a/apps/server/src/services/auto-mode/index.ts b/apps/server/src/services/auto-mode/index.ts index 9e150ad2..40e0ee84 100644 --- a/apps/server/src/services/auto-mode/index.ts +++ b/apps/server/src/services/auto-mode/index.ts @@ -58,6 +58,7 @@ export type { WorktreeCapacityInfo, RunningAgentInfo, OrphanedFeatureInfo, + FacadeError, GlobalAutoModeOperations, } from './types.js'; diff --git a/apps/server/src/services/auto-mode/types.ts b/apps/server/src/services/auto-mode/types.ts index b831daba..fc82cb13 100644 --- a/apps/server/src/services/auto-mode/types.ts +++ b/apps/server/src/services/auto-mode/types.ts @@ -15,6 +15,7 @@ import type { ConcurrencyManager } from '../concurrency-manager.js'; import type { AutoLoopCoordinator } from '../auto-loop-coordinator.js'; import type { WorktreeResolver } from '../worktree-resolver.js'; import type { TypedEventBus } from '../typed-event-bus.js'; +import type { ClaudeUsageService } from '../claude-usage-service.js'; // Re-export types from extracted services for route consumption export type { AutoModeConfig, ProjectAutoLoopState } from '../auto-loop-coordinator.js'; @@ -55,6 +56,8 @@ export interface FacadeOptions { featureLoader?: FeatureLoader; /** Shared services for state sharing across facades (optional) */ sharedServices?: SharedServices; + /** ClaudeUsageService for checking usage limits before picking up features (optional) */ + claudeUsageService?: ClaudeUsageService | null; } /** @@ -110,6 +113,23 @@ export interface OrphanedFeatureInfo { missingBranch: string; } +/** + * Structured error object returned/emitted by facade methods. + * Provides consistent error information for callers and UI consumers. + */ +export interface FacadeError { + /** The facade method where the error originated */ + method: string; + /** Classified error type from the error handler */ + errorType: import('@automaker/types').ErrorType; + /** Human-readable error message */ + message: string; + /** Feature ID if the error is associated with a specific feature */ + featureId?: string; + /** Project path where the error occurred */ + projectPath: string; +} + /** * Interface describing global auto-mode operations (not project-specific). * Used by routes that need global state access. diff --git a/apps/server/src/services/claude-usage-service.ts b/apps/server/src/services/claude-usage-service.ts index aa8afc1c..6438b5dc 100644 --- a/apps/server/src/services/claude-usage-service.ts +++ b/apps/server/src/services/claude-usage-service.ts @@ -294,7 +294,16 @@ export class ClaudeUsageService { this.killPtyProcess(ptyProcess); } // Don't fail if we have data - return it instead - if (output.includes('Current session')) { + // Check cleaned output since raw output has ANSI codes between words + // eslint-disable-next-line no-control-regex + const cleanedForCheck = output + .replace(/\x1B\[(\d+)C/g, (_m: string, n: string) => ' '.repeat(parseInt(n, 10))) + .replace(/\x1B\[[0-9;?]*[A-Za-z@]/g, ''); + if ( + cleanedForCheck.includes('Current session') || + cleanedForCheck.includes('% used') || + cleanedForCheck.includes('% left') + ) { resolve(output); } else if (hasSeenTrustPrompt) { // Trust prompt was shown but we couldn't auto-approve it @@ -320,8 +329,13 @@ export class ClaudeUsageService { output += data; // Strip ANSI codes for easier matching + // Convert cursor forward (ESC[nC) to spaces first to preserve word boundaries, + // then strip remaining ANSI sequences. Without this, the Claude CLI TUI output + // like "Current week (all models)" becomes "Currentweek(allmodels)". // eslint-disable-next-line no-control-regex - const cleanOutput = output.replace(/\x1B\[[0-9;]*[A-Za-z]/g, ''); + const cleanOutput = output + .replace(/\x1B\[(\d+)C/g, (_match: string, n: string) => ' '.repeat(parseInt(n, 10))) + .replace(/\x1B\[[0-9;?]*[A-Za-z@]/g, ''); // Check for specific authentication/permission errors // Must be very specific to avoid false positives from garbled terminal encoding @@ -356,7 +370,8 @@ export class ClaudeUsageService { const hasUsageIndicators = cleanOutput.includes('Current session') || (cleanOutput.includes('Usage') && cleanOutput.includes('% left')) || - // Additional patterns for winpty - look for percentage patterns + // Look for percentage patterns - allow optional whitespace between % and left/used + // since cursor movement codes may or may not create spaces after stripping /\d+%\s*(left|used|remaining)/i.test(cleanOutput) || cleanOutput.includes('Resets in') || cleanOutput.includes('Current week'); @@ -382,12 +397,15 @@ export class ClaudeUsageService { // Handle Trust Dialog - multiple variants: // - "Do you want to work in this folder?" // - "Ready to code here?" / "I'll need permission to work with your files" + // - "Quick safety check" / "Yes, I trust this folder" // Since we are running in cwd (project dir), it is safe to approve. if ( !hasApprovedTrust && (cleanOutput.includes('Do you want to work in this folder?') || cleanOutput.includes('Ready to code here') || - cleanOutput.includes('permission to work with your files')) + cleanOutput.includes('permission to work with your files') || + cleanOutput.includes('trust this folder') || + cleanOutput.includes('safety check')) ) { hasApprovedTrust = true; hasSeenTrustPrompt = true; @@ -471,10 +489,17 @@ export class ClaudeUsageService { * Handles CSI, OSC, and other common ANSI sequences */ private stripAnsiCodes(text: string): string { - // First strip ANSI sequences (colors, etc) and handle CR + // First, convert cursor movement sequences to whitespace to preserve word boundaries. + // The Claude CLI TUI uses ESC[nC (cursor forward) instead of actual spaces between words. + // Without this, "Current week (all models)" becomes "Currentweek(allmodels)" after stripping. // eslint-disable-next-line no-control-regex let clean = text - // CSI sequences: ESC [ ... (letter or @) + // Cursor forward (CSI n C): replace with n spaces to preserve word separation + .replace(/\x1B\[(\d+)C/g, (_match, n) => ' '.repeat(parseInt(n, 10))) + // Cursor movement (up/down/back/position): replace with newline or nothing + .replace(/\x1B\[\d*[ABD]/g, '') // cursor up (A), down (B), back (D) + .replace(/\x1B\[\d+;\d+[Hf]/g, '\n') // cursor position (H/f) + // Now strip remaining CSI sequences (colors, modes, etc.) .replace(/\x1B\[[0-9;?]*[A-Za-z@]/g, '') // OSC sequences: ESC ] ... terminated by BEL, ST, or another ESC .replace(/\x1B\][^\x07\x1B]*(?:\x07|\x1B\\)?/g, '') diff --git a/apps/server/src/services/feature-state-manager.ts b/apps/server/src/services/feature-state-manager.ts index cd35859e..e8afe0b9 100644 --- a/apps/server/src/services/feature-state-manager.ts +++ b/apps/server/src/services/feature-state-manager.ts @@ -107,6 +107,47 @@ export class FeatureStateManager { // Badge will show for 2 minutes after this timestamp if (status === 'waiting_approval') { feature.justFinishedAt = new Date().toISOString(); + + // Finalize task statuses when feature is done: + // - Mark any in_progress tasks as completed (agent finished but didn't explicitly complete them) + // - Do NOT mark pending tasks as completed (they were never started) + // - Clear currentTaskId since no task is actively running + // This prevents cards in "waiting for review" from appearing to still have running tasks + if (feature.planSpec?.tasks) { + let tasksFinalized = 0; + for (const task of feature.planSpec.tasks) { + if (task.status === 'in_progress') { + task.status = 'completed'; + tasksFinalized++; + } + } + if (tasksFinalized > 0) { + logger.info( + `[updateFeatureStatus] Finalized ${tasksFinalized} in_progress tasks for feature ${featureId} moving to waiting_approval` + ); + } + // Update tasksCompleted count to reflect actual completed tasks + feature.planSpec.tasksCompleted = feature.planSpec.tasks.filter( + (t) => t.status === 'completed' + ).length; + feature.planSpec.currentTaskId = undefined; + } + } else if (status === 'verified') { + // Also finalize in_progress tasks when moving directly to verified (skipTests=false) + // Do NOT mark pending tasks as completed - they were never started + if (feature.planSpec?.tasks) { + for (const task of feature.planSpec.tasks) { + if (task.status === 'in_progress') { + task.status = 'completed'; + } + } + feature.planSpec.tasksCompleted = feature.planSpec.tasks.filter( + (t) => t.status === 'completed' + ).length; + feature.planSpec.currentTaskId = undefined; + } + // Clear the timestamp when moving to other statuses + feature.justFinishedAt = undefined; } else { // Clear the timestamp when moving to other statuses feature.justFinishedAt = undefined; diff --git a/apps/server/tests/unit/services/claude-usage-service.test.ts b/apps/server/tests/unit/services/claude-usage-service.test.ts index 7901192c..bb88381e 100644 --- a/apps/server/tests/unit/services/claude-usage-service.test.ts +++ b/apps/server/tests/unit/services/claude-usage-service.test.ts @@ -177,6 +177,66 @@ describe('claude-usage-service.ts', () => { // BEL is stripped, newlines and tabs preserved expect(result).toBe('Line 1\nLine 2\tTabbed with bell'); }); + + it('should convert cursor forward (ESC[nC) to spaces', () => { + const service = new ClaudeUsageService(); + // Claude CLI TUI uses ESC[1C instead of space between words + const input = 'Current\x1B[1Csession'; + // @ts-expect-error - accessing private method for testing + const result = service.stripAnsiCodes(input); + + expect(result).toBe('Current session'); + }); + + it('should handle multi-character cursor forward sequences', () => { + const service = new ClaudeUsageService(); + // ESC[3C = move cursor forward 3 positions = 3 spaces + const input = 'Hello\x1B[3Cworld'; + // @ts-expect-error - accessing private method for testing + const result = service.stripAnsiCodes(input); + + expect(result).toBe('Hello world'); + }); + + it('should handle real Claude CLI TUI output with cursor movement codes', () => { + const service = new ClaudeUsageService(); + // Simulates actual Claude CLI /usage output where words are separated by ESC[1C + const input = + 'Current\x1B[1Cweek\x1B[1C(all\x1B[1Cmodels)\n' + + '\x1B[32mβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–Œ\x1B[0m\x1B[1C51%\x1B[1Cused\n' + + 'Resets\x1B[1CFeb\x1B[1C19\x1B[1Cat\x1B[1C3pm\x1B[1C(America/Los_Angeles)'; + // @ts-expect-error - accessing private method for testing + const result = service.stripAnsiCodes(input); + + expect(result).toContain('Current week (all models)'); + expect(result).toContain('51% used'); + expect(result).toContain('Resets Feb 19 at 3pm (America/Los_Angeles)'); + }); + + it('should parse usage output with cursor movement codes between words', () => { + const service = new ClaudeUsageService(); + // Simulates the full /usage TUI output with ESC[1C between every word + const output = + 'Current\x1B[1Csession\n' + + '\x1B[32mβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–Œ\x1B[0m\x1B[1C27%\x1B[1Cused\n' + + 'Resets\x1B[1C9pm\x1B[1C(America/Los_Angeles)\n' + + '\n' + + 'Current\x1B[1Cweek\x1B[1C(all\x1B[1Cmodels)\n' + + '\x1B[32mβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–Œ\x1B[0m\x1B[1C51%\x1B[1Cused\n' + + 'Resets\x1B[1CFeb\x1B[1C19\x1B[1Cat\x1B[1C3pm\x1B[1C(America/Los_Angeles)\n' + + '\n' + + 'Current\x1B[1Cweek\x1B[1C(Sonnet\x1B[1Conly)\n' + + '\x1B[32mβ–ˆβ–ˆβ–Œ\x1B[0m\x1B[1C5%\x1B[1Cused\n' + + 'Resets\x1B[1CFeb\x1B[1C19\x1B[1Cat\x1B[1C11pm\x1B[1C(America/Los_Angeles)'; + // @ts-expect-error - accessing private method for testing + const result = service.parseUsageOutput(output); + + expect(result.sessionPercentage).toBe(27); + expect(result.weeklyPercentage).toBe(51); + expect(result.sonnetWeeklyPercentage).toBe(5); + expect(result.weeklyResetText).toContain('Resets Feb 19 at 3pm'); + expect(result.weeklyResetText).not.toContain('America/Los_Angeles'); + }); }); describe('parseResetTime', () => { diff --git a/apps/server/tests/unit/services/feature-state-manager.test.ts b/apps/server/tests/unit/services/feature-state-manager.test.ts index d53c40b9..bff51d78 100644 --- a/apps/server/tests/unit/services/feature-state-manager.test.ts +++ b/apps/server/tests/unit/services/feature-state-manager.test.ts @@ -151,6 +151,96 @@ describe('FeatureStateManager', () => { expect(savedFeature.justFinishedAt).toBeUndefined(); }); + it('should finalize in_progress and pending tasks when moving to waiting_approval', async () => { + const featureWithTasks: Feature = { + ...mockFeature, + status: 'in_progress', + planSpec: { + status: 'approved', + version: 1, + reviewedByUser: true, + currentTaskId: 'task-2', + tasksCompleted: 1, + tasks: [ + { id: 'task-1', title: 'Task 1', status: 'completed', description: 'First task' }, + { id: 'task-2', title: 'Task 2', status: 'in_progress', description: 'Second task' }, + { id: 'task-3', title: 'Task 3', status: 'pending', description: 'Third task' }, + ], + }, + }; + + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: featureWithTasks, + recovered: false, + source: 'main', + }); + + await manager.updateFeatureStatus('/project', 'feature-123', 'waiting_approval'); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + // All tasks should be completed + expect(savedFeature.planSpec?.tasks?.[0].status).toBe('completed'); + expect(savedFeature.planSpec?.tasks?.[1].status).toBe('completed'); + expect(savedFeature.planSpec?.tasks?.[2].status).toBe('completed'); + // currentTaskId should be cleared + expect(savedFeature.planSpec?.currentTaskId).toBeUndefined(); + // tasksCompleted should equal total tasks + expect(savedFeature.planSpec?.tasksCompleted).toBe(3); + }); + + it('should finalize tasks when moving to verified status', async () => { + const featureWithTasks: Feature = { + ...mockFeature, + status: 'in_progress', + planSpec: { + status: 'approved', + version: 1, + reviewedByUser: true, + currentTaskId: 'task-2', + tasksCompleted: 1, + tasks: [ + { id: 'task-1', title: 'Task 1', status: 'completed', description: 'First task' }, + { id: 'task-2', title: 'Task 2', status: 'in_progress', description: 'Second task' }, + { id: 'task-3', title: 'Task 3', status: 'pending', description: 'Third task' }, + ], + }, + }; + + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: featureWithTasks, + recovered: false, + source: 'main', + }); + + await manager.updateFeatureStatus('/project', 'feature-123', 'verified'); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + // All tasks should be completed + expect(savedFeature.planSpec?.tasks?.[0].status).toBe('completed'); + expect(savedFeature.planSpec?.tasks?.[1].status).toBe('completed'); + expect(savedFeature.planSpec?.tasks?.[2].status).toBe('completed'); + // currentTaskId should be cleared + expect(savedFeature.planSpec?.currentTaskId).toBeUndefined(); + // tasksCompleted should equal total tasks + expect(savedFeature.planSpec?.tasksCompleted).toBe(3); + // justFinishedAt should be cleared for verified + expect(savedFeature.justFinishedAt).toBeUndefined(); + }); + + it('should handle waiting_approval without planSpec tasks gracefully', async () => { + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature }, + recovered: false, + source: 'main', + }); + + await manager.updateFeatureStatus('/project', 'feature-123', 'waiting_approval'); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + expect(savedFeature.status).toBe('waiting_approval'); + expect(savedFeature.justFinishedAt).toBeDefined(); + }); + it('should create notification for waiting_approval status', async () => { const mockNotificationService = { createNotification: vi.fn() }; (getNotificationService as Mock).mockReturnValue(mockNotificationService); diff --git a/apps/ui/src/components/ui/task-progress-panel.tsx b/apps/ui/src/components/ui/task-progress-panel.tsx index f72d6174..6ed40295 100644 --- a/apps/ui/src/components/ui/task-progress-panel.tsx +++ b/apps/ui/src/components/ui/task-progress-panel.tsx @@ -59,24 +59,19 @@ export function TaskProgressPanel({ const planSpec = feature.planSpec; const planTasks = planSpec.tasks; // Already guarded by the if condition above const currentId = planSpec.currentTaskId; - const completedCount = planSpec.tasksCompleted || 0; - // Convert planSpec tasks to TaskInfo with proper status + // Convert planSpec tasks to TaskInfo using their persisted status // planTasks is guaranteed to be defined due to the if condition check - const initialTasks: TaskInfo[] = (planTasks as ParsedTask[]).map( - (t: ParsedTask, index: number) => ({ - id: t.id, - description: t.description, - filePath: t.filePath, - phase: t.phase, - status: - index < completedCount - ? ('completed' as const) - : t.id === currentId - ? ('in_progress' as const) - : ('pending' as const), - }) - ); + const initialTasks: TaskInfo[] = (planTasks as ParsedTask[]).map((t: ParsedTask) => ({ + id: t.id, + description: t.description, + filePath: t.filePath, + phase: t.phase, + status: + t.id === currentId + ? ('in_progress' as const) + : (t.status as TaskInfo['status']) || ('pending' as const), + })); setTasks(initialTasks); setCurrentTaskId(currentId || null); @@ -113,16 +108,12 @@ export function TaskProgressPanel({ const existingIndex = prev.findIndex((t) => t.id === taskEvent.taskId); if (existingIndex !== -1) { - // Update status to in_progress and mark previous as completed - return prev.map((t, idx) => { + // Update only the started task to in_progress + // Do NOT assume previous tasks are completed - rely on actual task_complete events + return prev.map((t) => { if (t.id === taskEvent.taskId) { return { ...t, status: 'in_progress' as const }; } - // If we are moving to a task that is further down the list, assume previous ones are completed - // This is a heuristic, but usually correct for sequential execution - if (idx < existingIndex && t.status !== 'completed') { - return { ...t, status: 'completed' as const }; - } return t; }); } @@ -151,6 +142,24 @@ export function TaskProgressPanel({ setCurrentTaskId(null); } break; + + case 'auto_mode_task_status': + if ('taskId' in event && 'status' in event) { + const taskEvent = event as Extract; + setTasks((prev) => + prev.map((t) => + t.id === taskEvent.taskId + ? { ...t, status: taskEvent.status as TaskInfo['status'] } + : t + ) + ); + if (taskEvent.status === 'in_progress') { + setCurrentTaskId(taskEvent.taskId); + } else if (taskEvent.status === 'completed') { + setCurrentTaskId((current) => (current === taskEvent.taskId ? null : current)); + } + } + break; } }); diff --git a/apps/ui/src/components/usage-popover.tsx b/apps/ui/src/components/usage-popover.tsx index 5d8acb0b..b3f4347f 100644 --- a/apps/ui/src/components/usage-popover.tsx +++ b/apps/ui/src/components/usage-popover.tsx @@ -8,6 +8,7 @@ import { cn } from '@/lib/utils'; import { useSetupStore } from '@/store/setup-store'; import { AnthropicIcon, OpenAIIcon } from '@/components/ui/provider-icon'; import { useClaudeUsage, useCodexUsage } from '@/hooks/queries'; +import { getExpectedWeeklyPacePercentage, getPaceStatusLabel } from '@/store/utils/usage-utils'; // Error codes for distinguishing failure modes const ERROR_CODES = { @@ -146,13 +147,28 @@ export function UsagePopover() { return { color: 'text-green-500', icon: CheckCircle, bg: 'bg-green-500' }; }; - // Helper component for the progress bar - const ProgressBar = ({ percentage, colorClass }: { percentage: number; colorClass: string }) => ( -
+ // Helper component for the progress bar with optional pace indicator + const ProgressBar = ({ + percentage, + colorClass, + pacePercentage, + }: { + percentage: number; + colorClass: string; + pacePercentage?: number | null; + }) => ( +
+ {pacePercentage != null && pacePercentage > 0 && pacePercentage < 100 && ( +
+ )}
); @@ -163,6 +179,7 @@ export function UsagePopover() { resetText, isPrimary = false, stale = false, + pacePercentage, }: { title: string; subtitle: string; @@ -170,6 +187,7 @@ export function UsagePopover() { resetText?: string; isPrimary?: boolean; stale?: boolean; + pacePercentage?: number | null; }) => { const isValidPercentage = typeof percentage === 'number' && !isNaN(percentage) && isFinite(percentage); @@ -177,6 +195,10 @@ export function UsagePopover() { const status = getStatusInfo(safePercentage); const StatusIcon = status.icon; + const paceLabel = + isValidPercentage && pacePercentage != null + ? getPaceStatusLabel(safePercentage, pacePercentage) + : null; return (
- {resetText && ( -
+
+ {paceLabel ? ( +

(pacePercentage ?? 0) ? 'text-orange-500' : 'text-green-500' + )} + > + {paceLabel} +

+ ) : ( +
+ )} + {resetText && (

{resetText}

-
- )} + )} +
); }; @@ -384,6 +419,7 @@ export function UsagePopover() { percentage={claudeUsage.sonnetWeeklyPercentage} resetText={claudeUsage.sonnetResetText} stale={isClaudeStale} + pacePercentage={getExpectedWeeklyPacePercentage(claudeUsage.weeklyResetTime)} />
diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx index a3540cd7..80e42371 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx @@ -153,6 +153,7 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({ // Derive effective todos from planSpec.tasks when available, fallback to agentInfo.todos // Uses freshPlanSpec (from API) for accurate progress, with taskStatusMap for real-time updates + const isFeatureFinished = feature.status === 'waiting_approval' || feature.status === 'verified'; const effectiveTodos = useMemo(() => { // Use freshPlanSpec if available (fetched from API), fallback to store's feature.planSpec const planSpec = freshPlanSpec?.tasks?.length ? freshPlanSpec : feature.planSpec; @@ -163,6 +164,16 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({ const currentTaskId = planSpec.currentTaskId; return planSpec.tasks.map((task: ParsedTask, index: number) => { + // If the feature is done (waiting_approval/verified), all tasks are completed + // This is a defensive UI-side check: the server should have already finalized + // task statuses, but stale data from before the fix could still show spinners + if (isFeatureFinished) { + return { + content: task.description, + status: 'completed' as const, + }; + } + // Use real-time status from WebSocket events if available const realtimeStatus = taskStatusMap.get(task.id); @@ -199,6 +210,7 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({ feature.planSpec?.currentTaskId, agentInfo?.todos, taskStatusMap, + isFeatureFinished, ]); // Listen to WebSocket events for real-time task status updates diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx index bdf028b9..cc97b202 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx @@ -1,5 +1,6 @@ // @ts-nocheck - header component props with optional handlers and status variants import { memo, useState } from 'react'; +import type { DraggableAttributes, DraggableSyntheticListeners } from '@dnd-kit/core'; import { Feature } from '@/store/app-store'; import { cn } from '@/lib/utils'; import { CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; @@ -35,6 +36,8 @@ interface CardHeaderProps { onDelete: () => void; onViewOutput?: () => void; onSpawnTask?: () => void; + dragHandleListeners?: DraggableSyntheticListeners; + dragHandleAttributes?: DraggableAttributes; } export const CardHeaderSection = memo(function CardHeaderSection({ @@ -46,6 +49,8 @@ export const CardHeaderSection = memo(function CardHeaderSection({ onDelete, onViewOutput, onSpawnTask, + dragHandleListeners, + dragHandleAttributes, }: CardHeaderProps) { const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); @@ -319,8 +324,10 @@ export const CardHeaderSection = memo(function CardHeaderSection({
{isDraggable && (
diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx index 59bf8d8e..eb44c49b 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx @@ -32,7 +32,7 @@ function getCursorClass( ): string { if (isSelectionMode) return 'cursor-pointer'; if (isOverlay) return 'cursor-grabbing'; - if (isDraggable) return 'cursor-grab active:cursor-grabbing'; + // Drag cursor is now only on the drag handle, not the full card return 'cursor-default'; } @@ -172,7 +172,7 @@ export const KanbanCard = memo(function KanbanCard({ const isSelectable = isSelectionMode && feature.status === selectionTarget; const wrapperClasses = cn( - 'relative select-none outline-none touch-none transition-transform duration-200 ease-out', + 'relative select-none outline-none transition-transform duration-200 ease-out', getCursorClass(isOverlay, isDraggable, isSelectable), isOverlay && isLifted && 'scale-105 rotate-1 z-50', // Visual feedback when another card is being dragged over this one @@ -254,6 +254,8 @@ export const KanbanCard = memo(function KanbanCard({ onDelete={onDelete} onViewOutput={onViewOutput} onSpawnTask={onSpawnTask} + dragHandleListeners={isDraggable ? listeners : undefined} + dragHandleAttributes={isDraggable ? attributes : undefined} /> @@ -296,8 +298,6 @@ export const KanbanCard = memo(function KanbanCard({
diff --git a/apps/ui/src/components/views/board-view/mobile-usage-bar.tsx b/apps/ui/src/components/views/board-view/mobile-usage-bar.tsx index 918988e9..687de785 100644 --- a/apps/ui/src/components/views/board-view/mobile-usage-bar.tsx +++ b/apps/ui/src/components/views/board-view/mobile-usage-bar.tsx @@ -5,6 +5,7 @@ import { Spinner } from '@/components/ui/spinner'; import { getElectronAPI } from '@/lib/electron'; import { useAppStore } from '@/store/app-store'; import { AnthropicIcon, OpenAIIcon } from '@/components/ui/provider-icon'; +import { getExpectedWeeklyPacePercentage, getPaceStatusLabel } from '@/store/utils/usage-utils'; interface MobileUsageBarProps { showClaudeUsage: boolean; @@ -23,11 +24,15 @@ function UsageBar({ label, percentage, isStale, + pacePercentage, }: { label: string; percentage: number; isStale: boolean; + pacePercentage?: number | null; }) { + const paceLabel = pacePercentage != null ? getPaceStatusLabel(percentage, pacePercentage) : null; + return (
@@ -49,7 +54,7 @@ function UsageBar({
@@ -57,7 +62,24 @@ function UsageBar({ className={cn('h-full transition-all duration-500', getProgressBarColor(percentage))} style={{ width: `${Math.min(percentage, 100)}%` }} /> + {pacePercentage != null && pacePercentage > 0 && pacePercentage < 100 && ( +
+ )}
+ {paceLabel && ( +

(pacePercentage ?? 0) ? 'text-orange-500' : 'text-green-500' + )} + > + {paceLabel} +

+ )}
); } @@ -190,6 +212,7 @@ export function MobileUsageBar({ showClaudeUsage, showCodexUsage }: MobileUsageB label="Weekly" percentage={claudeUsage.weeklyPercentage} isStale={isClaudeStale} + pacePercentage={getExpectedWeeklyPacePercentage(claudeUsage.weeklyResetTime)} /> ) : ( diff --git a/apps/ui/src/store/utils/usage-utils.ts b/apps/ui/src/store/utils/usage-utils.ts index 7b82fb12..1c363f21 100644 --- a/apps/ui/src/store/utils/usage-utils.ts +++ b/apps/ui/src/store/utils/usage-utils.ts @@ -1,5 +1,64 @@ import type { ClaudeUsage } from '../types/usage-types'; +/** + * Calculate the expected weekly usage percentage based on how far through the week we are. + * Claude's weekly usage resets every Thursday. Given the reset time (when the NEXT reset occurs), + * we can determine how much of the week has elapsed and therefore what percentage of the budget + * should have been used if usage were evenly distributed. + * + * @param weeklyResetTime - ISO date string for when the weekly usage next resets + * @returns The expected usage percentage (0-100), or null if the reset time is invalid + */ +export function getExpectedWeeklyPacePercentage( + weeklyResetTime: string | undefined +): number | null { + if (!weeklyResetTime) return null; + + try { + const resetDate = new Date(weeklyResetTime); + if (isNaN(resetDate.getTime())) return null; + + const now = new Date(); + const WEEK_MS = 7 * 24 * 60 * 60 * 1000; + + // The week started 7 days before the reset + const weekStartDate = new Date(resetDate.getTime() - WEEK_MS); + + // How far through the week are we? + const elapsed = now.getTime() - weekStartDate.getTime(); + const fractionElapsed = elapsed / WEEK_MS; + + // Clamp to 0-1 range + const clamped = Math.max(0, Math.min(1, fractionElapsed)); + + return clamped * 100; + } catch { + return null; + } +} + +/** + * Get a human-readable label for the pace status (ahead or behind expected usage). + * + * @param actualPercentage - The actual usage percentage (0-100) + * @param expectedPercentage - The expected usage percentage (0-100) + * @returns A string like "5% ahead of pace" or "10% behind pace", or null + */ +export function getPaceStatusLabel( + actualPercentage: number, + expectedPercentage: number | null +): string | null { + if (expectedPercentage === null) return null; + + const diff = Math.round(actualPercentage - expectedPercentage); + + if (diff === 0) return 'On pace'; + // Using more than expected = behind pace (bad) + if (diff > 0) return `${Math.abs(diff)}% behind pace`; + // Using less than expected = ahead of pace (good) + return `${Math.abs(diff)}% ahead of pace`; +} + /** * Check if Claude usage is at its limit (any of: session >= 100%, weekly >= 100%, OR cost >= limit) * Returns true if any limit is reached, meaning auto mode should pause feature pickup. From 4a8c6b0eba0a3c32961f8a9edf2555842b6b1517 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 16 Feb 2026 20:47:38 -0800 Subject: [PATCH 090/156] Update feature-state-manager.test.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../tests/unit/services/feature-state-manager.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/server/tests/unit/services/feature-state-manager.test.ts b/apps/server/tests/unit/services/feature-state-manager.test.ts index bff51d78..65998ce1 100644 --- a/apps/server/tests/unit/services/feature-state-manager.test.ts +++ b/apps/server/tests/unit/services/feature-state-manager.test.ts @@ -178,14 +178,14 @@ describe('FeatureStateManager', () => { await manager.updateFeatureStatus('/project', 'feature-123', 'waiting_approval'); const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; - // All tasks should be completed + // Only in_progress tasks should be completed expect(savedFeature.planSpec?.tasks?.[0].status).toBe('completed'); expect(savedFeature.planSpec?.tasks?.[1].status).toBe('completed'); - expect(savedFeature.planSpec?.tasks?.[2].status).toBe('completed'); + expect(savedFeature.planSpec?.tasks?.[2].status).toBe('pending'); // currentTaskId should be cleared expect(savedFeature.planSpec?.currentTaskId).toBeUndefined(); - // tasksCompleted should equal total tasks - expect(savedFeature.planSpec?.tasksCompleted).toBe(3); + // tasksCompleted should be 2, not 3 + expect(savedFeature.planSpec?.tasksCompleted).toBe(2); }); it('should finalize tasks when moving to verified status', async () => { From 30fce3f7469bbd9ab04d2ae85be2dabb769df23e Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 16 Feb 2026 21:24:24 -0800 Subject: [PATCH 091/156] test: Update task finalization behavior to keep pending tasks in review states --- .../services/feature-state-manager.test.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/apps/server/tests/unit/services/feature-state-manager.test.ts b/apps/server/tests/unit/services/feature-state-manager.test.ts index 65998ce1..6abd4764 100644 --- a/apps/server/tests/unit/services/feature-state-manager.test.ts +++ b/apps/server/tests/unit/services/feature-state-manager.test.ts @@ -151,7 +151,7 @@ describe('FeatureStateManager', () => { expect(savedFeature.justFinishedAt).toBeUndefined(); }); - it('should finalize in_progress and pending tasks when moving to waiting_approval', async () => { + it('should finalize in_progress tasks but keep pending tasks when moving to waiting_approval', async () => { const featureWithTasks: Feature = { ...mockFeature, status: 'in_progress', @@ -178,13 +178,15 @@ describe('FeatureStateManager', () => { await manager.updateFeatureStatus('/project', 'feature-123', 'waiting_approval'); const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; - // Only in_progress tasks should be completed + // Already completed tasks stay completed expect(savedFeature.planSpec?.tasks?.[0].status).toBe('completed'); + // in_progress tasks should be finalized to completed expect(savedFeature.planSpec?.tasks?.[1].status).toBe('completed'); + // pending tasks should remain pending (never started) expect(savedFeature.planSpec?.tasks?.[2].status).toBe('pending'); // currentTaskId should be cleared expect(savedFeature.planSpec?.currentTaskId).toBeUndefined(); - // tasksCompleted should be 2, not 3 + // tasksCompleted should equal actual completed tasks count expect(savedFeature.planSpec?.tasksCompleted).toBe(2); }); @@ -215,14 +217,16 @@ describe('FeatureStateManager', () => { await manager.updateFeatureStatus('/project', 'feature-123', 'verified'); const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; - // All tasks should be completed + // Already completed tasks stay completed expect(savedFeature.planSpec?.tasks?.[0].status).toBe('completed'); + // in_progress tasks should be finalized to completed expect(savedFeature.planSpec?.tasks?.[1].status).toBe('completed'); - expect(savedFeature.planSpec?.tasks?.[2].status).toBe('completed'); + // pending tasks should remain pending (never started) + expect(savedFeature.planSpec?.tasks?.[2].status).toBe('pending'); // currentTaskId should be cleared expect(savedFeature.planSpec?.currentTaskId).toBeUndefined(); - // tasksCompleted should equal total tasks - expect(savedFeature.planSpec?.tasksCompleted).toBe(3); + // tasksCompleted should equal actual completed tasks count + expect(savedFeature.planSpec?.tasksCompleted).toBe(2); // justFinishedAt should be cleared for verified expect(savedFeature.justFinishedAt).toBeUndefined(); }); From aa940d44ff0692357633f5d1f6bcb2cf5071520f Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 16 Feb 2026 22:10:50 -0800 Subject: [PATCH 092/156] feat: Add task retry logic and improve max turns limit --- apps/server/src/services/agent-executor.ts | 2 +- apps/server/src/services/execution-service.ts | 93 +++++- .../src/services/feature-state-manager.ts | 23 ++ .../unit/services/execution-service.test.ts | 296 ++++++++++++++++++ .../kanban-card/agent-info-panel.tsx | 11 +- 5 files changed, 419 insertions(+), 6 deletions(-) diff --git a/apps/server/src/services/agent-executor.ts b/apps/server/src/services/agent-executor.ts index 0d9c2399..cbf9e401 100644 --- a/apps/server/src/services/agent-executor.ts +++ b/apps/server/src/services/agent-executor.ts @@ -331,7 +331,7 @@ export class AgentExecutor { userFeedback ); const taskStream = provider.executeQuery( - this.buildExecOpts(options, taskPrompt, Math.min(sdkOptions?.maxTurns ?? 50, 50)) + this.buildExecOpts(options, taskPrompt, Math.min(sdkOptions?.maxTurns ?? 50, 150)) ); let taskOutput = '', taskStartDetected = false, diff --git a/apps/server/src/services/execution-service.ts b/apps/server/src/services/execution-service.ts index 75bb10bd..f7a51ace 100644 --- a/apps/server/src/services/execution-service.ts +++ b/apps/server/src/services/execution-service.ts @@ -270,6 +270,84 @@ ${feature.spec} } ); + // Check for incomplete tasks after agent execution. + // The agent may have finished early (hit max turns, decided it was done, etc.) + // while tasks are still pending. If so, re-run the agent to complete remaining tasks. + const MAX_TASK_RETRY_ATTEMPTS = 3; + let taskRetryAttempts = 0; + while (!abortController.signal.aborted && taskRetryAttempts < MAX_TASK_RETRY_ATTEMPTS) { + const currentFeature = await this.loadFeatureFn(projectPath, featureId); + if (!currentFeature?.planSpec?.tasks) break; + + const pendingTasks = currentFeature.planSpec.tasks.filter( + (t) => t.status === 'pending' || t.status === 'in_progress' + ); + if (pendingTasks.length === 0) break; + + taskRetryAttempts++; + const totalTasks = currentFeature.planSpec.tasks.length; + const completedTasks = currentFeature.planSpec.tasks.filter( + (t) => t.status === 'completed' + ).length; + logger.info( + `[executeFeature] Feature ${featureId} has ${pendingTasks.length} incomplete tasks (${completedTasks}/${totalTasks} completed). Re-running agent (attempt ${taskRetryAttempts}/${MAX_TASK_RETRY_ATTEMPTS})` + ); + + this.eventBus.emitAutoModeEvent('auto_mode_progress', { + featureId, + branchName: feature.branchName ?? null, + content: `Agent finished with ${pendingTasks.length} tasks remaining. Re-running to complete tasks (attempt ${taskRetryAttempts}/${MAX_TASK_RETRY_ATTEMPTS})...`, + projectPath, + }); + + // Build a continuation prompt that tells the agent to finish remaining tasks + const remainingTasksList = pendingTasks + .map((t) => `- ${t.id}: ${t.description} (${t.status})`) + .join('\n'); + + const continuationPrompt = `## Continue Implementation - Incomplete Tasks + +The previous agent session ended before all tasks were completed. Please continue implementing the remaining tasks. + +**Completed:** ${completedTasks}/${totalTasks} tasks +**Remaining tasks:** +${remainingTasksList} + +Please continue from where you left off and complete all remaining tasks. Use the same [TASK_START:ID] and [TASK_COMPLETE:ID] markers for each task.`; + + await this.runAgentFn( + workDir, + featureId, + continuationPrompt, + abortController, + projectPath, + undefined, + model, + { + projectPath, + planningMode: 'skip', + requirePlanApproval: false, + systemPrompt: combinedSystemPrompt || undefined, + autoLoadClaudeMd, + thinkingLevel: feature.thinkingLevel, + branchName: feature.branchName ?? null, + } + ); + } + + // Log if tasks are still incomplete after retry attempts + if (taskRetryAttempts >= MAX_TASK_RETRY_ATTEMPTS) { + const finalFeature = await this.loadFeatureFn(projectPath, featureId); + const stillPending = finalFeature?.planSpec?.tasks?.filter( + (t) => t.status === 'pending' || t.status === 'in_progress' + ); + if (stillPending && stillPending.length > 0) { + logger.warn( + `[executeFeature] Feature ${featureId} still has ${stillPending.length} incomplete tasks after ${MAX_TASK_RETRY_ATTEMPTS} retry attempts. Moving to final status.` + ); + } + } + const pipelineConfig = await pipelineService.getPipelineConfig(projectPath); const excludedStepIds = new Set(feature.excludedPipelineSteps || []); const sortedSteps = [...(pipelineConfig?.steps || [])] @@ -300,6 +378,13 @@ ${feature.spec} await this.updateFeatureStatusFn(projectPath, featureId, finalStatus); this.recordSuccessFn(); + // Check final task completion state for accurate reporting + const completedFeature = await this.loadFeatureFn(projectPath, featureId); + const totalTasks = completedFeature?.planSpec?.tasks?.length ?? 0; + const completedTasks = + completedFeature?.planSpec?.tasks?.filter((t) => t.status === 'completed').length ?? 0; + const hasIncompleteTasks = totalTasks > 0 && completedTasks < totalTasks; + try { const outputPath = path.join(getFeatureDir(projectPath, featureId), 'agent-output.md'); let agentOutput = ''; @@ -326,12 +411,18 @@ ${feature.spec} /* learnings recording failed */ } + const elapsedSeconds = Math.round((Date.now() - tempRunningFeature.startTime) / 1000); + let completionMessage = `Feature completed in ${elapsedSeconds}s`; + if (finalStatus === 'verified') completionMessage += ' - auto-verified'; + if (hasIncompleteTasks) + completionMessage += ` (${completedTasks}/${totalTasks} tasks completed)`; + this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', { featureId, featureName: feature.title, branchName: feature.branchName ?? null, passes: true, - message: `Feature completed in ${Math.round((Date.now() - tempRunningFeature.startTime) / 1000)}s${finalStatus === 'verified' ? ' - auto-verified' : ''}`, + message: completionMessage, projectPath, model: tempRunningFeature.model, provider: tempRunningFeature.provider, diff --git a/apps/server/src/services/feature-state-manager.ts b/apps/server/src/services/feature-state-manager.ts index e8afe0b9..b33f6df6 100644 --- a/apps/server/src/services/feature-state-manager.ts +++ b/apps/server/src/services/feature-state-manager.ts @@ -115,10 +115,13 @@ export class FeatureStateManager { // This prevents cards in "waiting for review" from appearing to still have running tasks if (feature.planSpec?.tasks) { let tasksFinalized = 0; + let tasksPending = 0; for (const task of feature.planSpec.tasks) { if (task.status === 'in_progress') { task.status = 'completed'; tasksFinalized++; + } else if (task.status === 'pending') { + tasksPending++; } } if (tasksFinalized > 0) { @@ -126,6 +129,11 @@ export class FeatureStateManager { `[updateFeatureStatus] Finalized ${tasksFinalized} in_progress tasks for feature ${featureId} moving to waiting_approval` ); } + if (tasksPending > 0) { + logger.warn( + `[updateFeatureStatus] Feature ${featureId} moving to waiting_approval with ${tasksPending} pending (never started) tasks out of ${feature.planSpec.tasks.length} total` + ); + } // Update tasksCompleted count to reflect actual completed tasks feature.planSpec.tasksCompleted = feature.planSpec.tasks.filter( (t) => t.status === 'completed' @@ -136,11 +144,26 @@ export class FeatureStateManager { // Also finalize in_progress tasks when moving directly to verified (skipTests=false) // Do NOT mark pending tasks as completed - they were never started if (feature.planSpec?.tasks) { + let tasksFinalized = 0; + let tasksPending = 0; for (const task of feature.planSpec.tasks) { if (task.status === 'in_progress') { task.status = 'completed'; + tasksFinalized++; + } else if (task.status === 'pending') { + tasksPending++; } } + if (tasksFinalized > 0) { + logger.info( + `[updateFeatureStatus] Finalized ${tasksFinalized} in_progress tasks for feature ${featureId} moving to verified` + ); + } + if (tasksPending > 0) { + logger.warn( + `[updateFeatureStatus] Feature ${featureId} moving to verified with ${tasksPending} pending (never started) tasks out of ${feature.planSpec.tasks.length} total` + ); + } feature.planSpec.tasksCompleted = feature.planSpec.tasks.filter( (t) => t.status === 'completed' ).length; diff --git a/apps/server/tests/unit/services/execution-service.test.ts b/apps/server/tests/unit/services/execution-service.test.ts index 0a0ca57d..497c6abd 100644 --- a/apps/server/tests/unit/services/execution-service.test.ts +++ b/apps/server/tests/unit/services/execution-service.test.ts @@ -677,6 +677,302 @@ describe('execution-service.ts', () => { }); }); + describe('executeFeature - incomplete task retry', () => { + const createServiceWithMocks = () => { + return new ExecutionService( + mockEventBus, + mockConcurrencyManager, + mockWorktreeResolver, + mockSettingsService, + mockRunAgentFn, + mockExecutePipelineFn, + mockUpdateFeatureStatusFn, + mockLoadFeatureFn, + mockGetPlanningPromptPrefixFn, + mockSaveFeatureSummaryFn, + mockRecordLearningsFn, + mockContextExistsFn, + mockResumeFeatureFn, + mockTrackFailureFn, + mockSignalPauseFn, + mockRecordSuccessFn, + mockSaveExecutionStateFn, + mockLoadContextFilesFn + ); + }; + + it('does not re-run agent when feature has no tasks', async () => { + // Feature with no planSpec/tasks - should complete normally with 1 agent call + mockLoadFeatureFn = vi.fn().mockResolvedValue(testFeature); + const svc = createServiceWithMocks(); + + await svc.executeFeature('/test/project', 'feature-1'); + + expect(mockRunAgentFn).toHaveBeenCalledTimes(1); + }); + + it('does not re-run agent when all tasks are completed', async () => { + const featureWithCompletedTasks: Feature = { + ...testFeature, + planSpec: { + status: 'approved', + content: 'Plan', + tasks: [ + { id: 'T001', title: 'Task 1', status: 'completed', description: 'First task' }, + { id: 'T002', title: 'Task 2', status: 'completed', description: 'Second task' }, + ], + tasksCompleted: 2, + }, + }; + mockLoadFeatureFn = vi.fn().mockResolvedValue(featureWithCompletedTasks); + const svc = createServiceWithMocks(); + + await svc.executeFeature('/test/project', 'feature-1'); + + // Only the initial agent call + the approved-plan recursive call + // The approved plan triggers recursive executeFeature, so runAgentFn is called once in the inner call + expect(mockRunAgentFn).toHaveBeenCalledTimes(1); + }); + + it('re-runs agent when there are pending tasks after initial execution', async () => { + const featureWithPendingTasks: Feature = { + ...testFeature, + planSpec: { + status: 'approved', + content: 'Plan', + tasks: [ + { id: 'T001', title: 'Task 1', status: 'completed', description: 'First task' }, + { id: 'T002', title: 'Task 2', status: 'pending', description: 'Second task' }, + { id: 'T003', title: 'Task 3', status: 'pending', description: 'Third task' }, + ], + tasksCompleted: 1, + }, + }; + + // After first agent run, loadFeature returns feature with pending tasks + // After second agent run, loadFeature returns feature with all tasks completed + const featureAllDone: Feature = { + ...testFeature, + planSpec: { + status: 'approved', + content: 'Plan', + tasks: [ + { id: 'T001', title: 'Task 1', status: 'completed', description: 'First task' }, + { id: 'T002', title: 'Task 2', status: 'completed', description: 'Second task' }, + { id: 'T003', title: 'Task 3', status: 'completed', description: 'Third task' }, + ], + tasksCompleted: 3, + }, + }; + + let loadCallCount = 0; + mockLoadFeatureFn = vi.fn().mockImplementation(() => { + loadCallCount++; + // First call: initial feature load at the top of executeFeature + // Second call: after first agent run (check for incomplete tasks) - has pending tasks + // Third call: after second agent run (check for incomplete tasks) - all done + if (loadCallCount <= 2) return featureWithPendingTasks; + return featureAllDone; + }); + + const svc = createServiceWithMocks(); + await svc.executeFeature('/test/project', 'feature-1', false, false, undefined, { + continuationPrompt: 'Continue', + _calledInternally: true, + }); + + // Should have called runAgentFn twice: initial + one retry + expect(mockRunAgentFn).toHaveBeenCalledTimes(2); + + // The retry call should contain continuation prompt about incomplete tasks + const retryCallArgs = mockRunAgentFn.mock.calls[1]; + expect(retryCallArgs[2]).toContain('Continue Implementation - Incomplete Tasks'); + expect(retryCallArgs[2]).toContain('T002'); + expect(retryCallArgs[2]).toContain('T003'); + + // Should have emitted a progress event about retrying + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( + 'auto_mode_progress', + expect.objectContaining({ + featureId: 'feature-1', + content: expect.stringContaining('Re-running to complete tasks'), + }) + ); + }); + + it('respects maximum retry attempts', async () => { + const featureAlwaysPending: Feature = { + ...testFeature, + planSpec: { + status: 'approved', + content: 'Plan', + tasks: [ + { id: 'T001', title: 'Task 1', status: 'completed', description: 'First task' }, + { id: 'T002', title: 'Task 2', status: 'pending', description: 'Second task' }, + ], + tasksCompleted: 1, + }, + }; + + // Always return feature with pending tasks (agent never completes T002) + mockLoadFeatureFn = vi.fn().mockResolvedValue(featureAlwaysPending); + + const svc = createServiceWithMocks(); + await svc.executeFeature('/test/project', 'feature-1', false, false, undefined, { + continuationPrompt: 'Continue', + _calledInternally: true, + }); + + // Initial run + 3 retry attempts = 4 total + expect(mockRunAgentFn).toHaveBeenCalledTimes(4); + + // Should still set final status even with incomplete tasks + expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'verified' + ); + }); + + it('stops retrying when abort signal is triggered', async () => { + const featureWithPendingTasks: Feature = { + ...testFeature, + planSpec: { + status: 'approved', + content: 'Plan', + tasks: [ + { id: 'T001', title: 'Task 1', status: 'completed', description: 'First task' }, + { id: 'T002', title: 'Task 2', status: 'pending', description: 'Second task' }, + ], + tasksCompleted: 1, + }, + }; + + mockLoadFeatureFn = vi.fn().mockResolvedValue(featureWithPendingTasks); + + // Simulate abort after first agent run + let runCount = 0; + const capturedAbortController = { current: null as AbortController | null }; + mockRunAgentFn = vi.fn().mockImplementation((_wd, _fid, _prompt, abortCtrl) => { + capturedAbortController.current = abortCtrl; + runCount++; + if (runCount >= 1) { + // Abort after first run + abortCtrl.abort(); + } + return Promise.resolve(); + }); + + const svc = createServiceWithMocks(); + await svc.executeFeature('/test/project', 'feature-1', false, false, undefined, { + continuationPrompt: 'Continue', + _calledInternally: true, + }); + + // Should only have the initial run, then abort prevents retries + expect(mockRunAgentFn).toHaveBeenCalledTimes(1); + }); + + it('re-runs agent for in_progress tasks (not just pending)', async () => { + const featureWithInProgressTask: Feature = { + ...testFeature, + planSpec: { + status: 'approved', + content: 'Plan', + tasks: [ + { id: 'T001', title: 'Task 1', status: 'completed', description: 'First task' }, + { id: 'T002', title: 'Task 2', status: 'in_progress', description: 'Second task' }, + ], + tasksCompleted: 1, + currentTaskId: 'T002', + }, + }; + + const featureAllDone: Feature = { + ...testFeature, + planSpec: { + status: 'approved', + content: 'Plan', + tasks: [ + { id: 'T001', title: 'Task 1', status: 'completed', description: 'First task' }, + { id: 'T002', title: 'Task 2', status: 'completed', description: 'Second task' }, + ], + tasksCompleted: 2, + }, + }; + + let loadCallCount = 0; + mockLoadFeatureFn = vi.fn().mockImplementation(() => { + loadCallCount++; + if (loadCallCount <= 2) return featureWithInProgressTask; + return featureAllDone; + }); + + const svc = createServiceWithMocks(); + await svc.executeFeature('/test/project', 'feature-1', false, false, undefined, { + continuationPrompt: 'Continue', + _calledInternally: true, + }); + + // Should have retried for the in_progress task + expect(mockRunAgentFn).toHaveBeenCalledTimes(2); + + // The retry prompt should mention the in_progress task + const retryCallArgs = mockRunAgentFn.mock.calls[1]; + expect(retryCallArgs[2]).toContain('T002'); + expect(retryCallArgs[2]).toContain('in_progress'); + }); + + it('uses planningMode skip and no plan approval for retry runs', async () => { + const featureWithPendingTasks: Feature = { + ...testFeature, + planningMode: 'full', + requirePlanApproval: true, + planSpec: { + status: 'approved', + content: 'Plan', + tasks: [ + { id: 'T001', title: 'Task 1', status: 'completed', description: 'First task' }, + { id: 'T002', title: 'Task 2', status: 'pending', description: 'Second task' }, + ], + tasksCompleted: 1, + }, + }; + + const featureAllDone: Feature = { + ...testFeature, + planSpec: { + status: 'approved', + content: 'Plan', + tasks: [ + { id: 'T001', title: 'Task 1', status: 'completed', description: 'First task' }, + { id: 'T002', title: 'Task 2', status: 'completed', description: 'Second task' }, + ], + tasksCompleted: 2, + }, + }; + + let loadCallCount = 0; + mockLoadFeatureFn = vi.fn().mockImplementation(() => { + loadCallCount++; + if (loadCallCount <= 2) return featureWithPendingTasks; + return featureAllDone; + }); + + const svc = createServiceWithMocks(); + await svc.executeFeature('/test/project', 'feature-1', false, false, undefined, { + continuationPrompt: 'Continue', + _calledInternally: true, + }); + + // The retry agent call should use planningMode: 'skip' and requirePlanApproval: false + const retryCallArgs = mockRunAgentFn.mock.calls[1]; + const retryOptions = retryCallArgs[7]; // options object + expect(retryOptions.planningMode).toBe('skip'); + expect(retryOptions.requirePlanApproval).toBe(false); + }); + }); + describe('executeFeature - error handling', () => { it('classifies and emits error event', async () => { const testError = new Error('Test error'); diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx index 80e42371..0e59e3bd 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx @@ -164,13 +164,16 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({ const currentTaskId = planSpec.currentTaskId; return planSpec.tasks.map((task: ParsedTask, index: number) => { - // If the feature is done (waiting_approval/verified), all tasks are completed - // This is a defensive UI-side check: the server should have already finalized - // task statuses, but stale data from before the fix could still show spinners + // When feature is finished (waiting_approval/verified), finalize task display: + // - in_progress tasks β†’ completed (agent was working on them when it finished) + // - pending tasks stay pending (they were never started) + // - completed tasks stay completed + // This matches server-side behavior in feature-state-manager.ts if (isFeatureFinished) { + const finalStatus = task.status === 'in_progress' ? 'completed' : task.status; return { content: task.description, - status: 'completed' as const, + status: (finalStatus || 'completed') as 'pending' | 'in_progress' | 'completed', }; } From d5340fd1a40426924d93832ccffb89aacae317e7 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 16 Feb 2026 22:19:26 -0800 Subject: [PATCH 093/156] Update apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../board-view/components/kanban-card/agent-info-panel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx index 0e59e3bd..1fae2128 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx @@ -170,7 +170,7 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({ // - completed tasks stay completed // This matches server-side behavior in feature-state-manager.ts if (isFeatureFinished) { - const finalStatus = task.status === 'in_progress' ? 'completed' : task.status; + const finalStatus = task.status === 'in_progress' || task.status === 'failed' ? 'completed' : task.status; return { content: task.description, status: (finalStatus || 'completed') as 'pending' | 'in_progress' | 'completed', From 8af1b8bd0807ab5f11394cf7d9851d8af4150005 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 16 Feb 2026 22:38:59 -0800 Subject: [PATCH 094/156] chore: Increase default max turns for agent execution from 20/50 to 100 --- apps/server/src/providers/claude-provider.ts | 2 +- apps/server/src/services/agent-executor.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/src/providers/claude-provider.ts b/apps/server/src/providers/claude-provider.ts index 78a0a0c7..3d4f88cd 100644 --- a/apps/server/src/providers/claude-provider.ts +++ b/apps/server/src/providers/claude-provider.ts @@ -204,7 +204,7 @@ export class ClaudeProvider extends BaseProvider { model, cwd, systemPrompt, - maxTurns = 20, + maxTurns = 100, allowedTools, abortController, conversationHistory, diff --git a/apps/server/src/services/agent-executor.ts b/apps/server/src/services/agent-executor.ts index cbf9e401..5d049804 100644 --- a/apps/server/src/services/agent-executor.ts +++ b/apps/server/src/services/agent-executor.ts @@ -331,7 +331,7 @@ export class AgentExecutor { userFeedback ); const taskStream = provider.executeQuery( - this.buildExecOpts(options, taskPrompt, Math.min(sdkOptions?.maxTurns ?? 50, 150)) + this.buildExecOpts(options, taskPrompt, Math.min(sdkOptions?.maxTurns ?? 100, 100)) ); let taskOutput = '', taskStartDetected = false, From f06088a062122869ae1750d77b31d8b92e88af47 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 16 Feb 2026 22:47:30 -0800 Subject: [PATCH 095/156] feat: Update maxTurns default from 20 to 100 and format code --- apps/server/tests/unit/providers/claude-provider.test.ts | 4 ++-- .../board-view/components/kanban-card/agent-info-panel.tsx | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/server/tests/unit/providers/claude-provider.test.ts b/apps/server/tests/unit/providers/claude-provider.test.ts index 7df211ef..a2ebd72a 100644 --- a/apps/server/tests/unit/providers/claude-provider.test.ts +++ b/apps/server/tests/unit/providers/claude-provider.test.ts @@ -187,7 +187,7 @@ describe('claude-provider.ts', () => { expect(typeof callArgs.prompt).not.toBe('string'); }); - it('should use maxTurns default of 20', async () => { + it('should use maxTurns default of 100', async () => { vi.mocked(sdk.query).mockReturnValue( (async function* () { yield { type: 'text', text: 'test' }; @@ -205,7 +205,7 @@ describe('claude-provider.ts', () => { expect(sdk.query).toHaveBeenCalledWith({ prompt: 'Test', options: expect.objectContaining({ - maxTurns: 20, + maxTurns: 100, }), }); }); diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx index 1fae2128..2d215252 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx @@ -170,7 +170,8 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({ // - completed tasks stay completed // This matches server-side behavior in feature-state-manager.ts if (isFeatureFinished) { - const finalStatus = task.status === 'in_progress' || task.status === 'failed' ? 'completed' : task.status; + const finalStatus = + task.status === 'in_progress' || task.status === 'failed' ? 'completed' : task.status; return { content: task.description, status: (finalStatus || 'completed') as 'pending' | 'in_progress' | 'completed', From fa799d3cb53bb0b0ed0e64e4123764d4a85ac736 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 16 Feb 2026 23:08:09 -0800 Subject: [PATCH 096/156] feat: Implement optimistic updates for feature persistence Add optimistic UI updates with rollback capability for feature creation and deletion operations. Await persistFeatureDelete promise and add Playwright testing dependency. --- .../board-view/hooks/use-board-actions.ts | 2 +- .../board-view/hooks/use-board-persistence.ts | 36 ++++++++++++++++--- package-lock.json | 13 ++----- 3 files changed, 34 insertions(+), 17 deletions(-) diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts b/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts index 4f3c0517..b0a917fe 100644 --- a/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts +++ b/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts @@ -510,7 +510,7 @@ export function useBoardActions({ } removeFeature(featureId); - persistFeatureDelete(featureId); + await persistFeatureDelete(featureId); }, [features, runningAutoTasks, autoMode, removeFeature, persistFeatureDelete] ); diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-persistence.ts b/apps/ui/src/components/views/board-view/hooks/use-board-persistence.ts index 6e5d23f5..0d0ec346 100644 --- a/apps/ui/src/components/views/board-view/hooks/use-board-persistence.ts +++ b/apps/ui/src/components/views/board-view/hooks/use-board-persistence.ts @@ -86,16 +86,26 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps return; } + // Optimistically add to React Query cache for immediate board refresh + queryClient.setQueryData( + queryKeys.features.all(currentProject.path), + (existing) => (existing ? [...existing, feature] : [feature]) + ); + const result = await api.features.create(currentProject.path, feature as ApiFeature); if (result.success && result.feature) { updateFeature(result.feature.id, result.feature as Partial); - // Invalidate React Query cache to sync UI - queryClient.invalidateQueries({ - queryKey: queryKeys.features.all(currentProject.path), - }); } + // Always invalidate to sync with server state + queryClient.invalidateQueries({ + queryKey: queryKeys.features.all(currentProject.path), + }); } catch (error) { logger.error('Failed to persist feature creation:', error); + // Rollback optimistic update on error + queryClient.invalidateQueries({ + queryKey: queryKeys.features.all(currentProject.path), + }); } }, [currentProject, updateFeature, queryClient] @@ -106,6 +116,15 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps async (featureId: string) => { if (!currentProject) return; + // Optimistically remove from React Query cache for immediate board refresh + const previousFeatures = queryClient.getQueryData( + queryKeys.features.all(currentProject.path) + ); + queryClient.setQueryData( + queryKeys.features.all(currentProject.path), + (existing) => (existing ? existing.filter((f) => f.id !== featureId) : existing) + ); + try { const api = getElectronAPI(); if (!api.features) { @@ -114,12 +133,19 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps } await api.features.delete(currentProject.path, featureId); - // Invalidate React Query cache to sync UI + // Invalidate to sync with server state queryClient.invalidateQueries({ queryKey: queryKeys.features.all(currentProject.path), }); } catch (error) { logger.error('Failed to persist feature deletion:', error); + // Rollback optimistic update on error + if (previousFeatures) { + queryClient.setQueryData(queryKeys.features.all(currentProject.path), previousFeatures); + } + queryClient.invalidateQueries({ + queryKey: queryKeys.features.all(currentProject.path), + }); } }, [currentProject, queryClient] diff --git a/package-lock.json b/package-lock.json index 8804b479..3c60eba6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "automaker", "version": "0.13.0", "hasInstallScript": true, + "license": "MIT", "workspaces": [ "apps/*", "libs/*" @@ -56,6 +57,7 @@ "yaml": "2.7.0" }, "devDependencies": { + "@playwright/test": "1.57.0", "@types/cookie": "0.6.0", "@types/cookie-parser": "1.4.10", "@types/cors": "2.8.19", @@ -11475,7 +11477,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11497,7 +11498,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11519,7 +11519,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11541,7 +11540,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11563,7 +11561,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11585,7 +11582,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11607,7 +11603,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11629,7 +11624,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11651,7 +11645,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11673,7 +11666,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11695,7 +11687,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, From b9653d633888137d5e4e91a5d73d1e698f239b9d Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 16 Feb 2026 23:41:08 -0800 Subject: [PATCH 097/156] fix: Strip runtime and state fields when duplicating features --- .../views/board-view/hooks/use-board-actions.ts | 16 ++++++++++++++-- .../board-view/hooks/use-board-persistence.ts | 7 +++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts b/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts index 89446fc5..75d49030 100644 --- a/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts +++ b/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts @@ -1092,8 +1092,20 @@ export function useBoardActions({ const handleDuplicateFeature = useCallback( async (feature: Feature, asChild: boolean = false) => { - // Copy all feature data, only override id/status (handled by create) and dependencies if as child - const { id: _id, status: _status, ...featureData } = feature; + // Copy all feature data, stripping id, status (handled by create), and runtime/state fields + const { + id: _id, + status: _status, + startedAt: _startedAt, + error: _error, + summary: _summary, + spec: _spec, + passes: _passes, + planSpec: _planSpec, + descriptionHistory: _descriptionHistory, + titleGenerating: _titleGenerating, + ...featureData + } = feature; const duplicatedFeatureData = { ...featureData, // If duplicating as child, set source as dependency; otherwise keep existing diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-persistence.ts b/apps/ui/src/components/views/board-view/hooks/use-board-persistence.ts index d0da2d5c..d3004f74 100644 --- a/apps/ui/src/components/views/board-view/hooks/use-board-persistence.ts +++ b/apps/ui/src/components/views/board-view/hooks/use-board-persistence.ts @@ -132,6 +132,13 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps const api = getElectronAPI(); if (!api.features) { logger.error('Features API not available'); + // Rollback optimistic deletion since we can't persist + if (previousFeatures) { + queryClient.setQueryData(queryKeys.features.all(currentProject.path), previousFeatures); + } + queryClient.invalidateQueries({ + queryKey: queryKeys.features.all(currentProject.path), + }); return; } From a09a2c76ae2c4bf14df564ccdf6363186d815640 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 17 Feb 2026 00:13:38 -0800 Subject: [PATCH 098/156] fix: Address code review feedback and fix lint errors --- apps/server/eslint.config.mjs | 74 +++++++ .../routes/worktree/routes/checkout-branch.ts | 2 +- .../routes/worktree/routes/open-in-editor.ts | 21 +- .../src/services/claude-usage-service.ts | 2 +- .../server/src/services/dev-server-service.ts | 2 +- apps/server/src/services/ideation-service.ts | 2 +- apps/ui/eslint.config.mjs | 10 +- apps/ui/src/components/views/board-view.tsx | 3 +- .../components/kanban-card/card-header.tsx | 191 ++++++------------ .../board-view/hooks/use-board-persistence.ts | 23 ++- .../views/board-view/kanban-board.tsx | 12 +- .../components/edit-mode/features-section.tsx | 1 - .../components/edit-mode/roadmap-section.tsx | 1 - apps/ui/src/lib/electron.ts | 1 - apps/ui/src/store/test-runners-store.ts | 3 - 15 files changed, 183 insertions(+), 165 deletions(-) create mode 100644 apps/server/eslint.config.mjs diff --git a/apps/server/eslint.config.mjs b/apps/server/eslint.config.mjs new file mode 100644 index 00000000..008c1f68 --- /dev/null +++ b/apps/server/eslint.config.mjs @@ -0,0 +1,74 @@ +import { defineConfig, globalIgnores } from 'eslint/config'; +import js from '@eslint/js'; +import ts from '@typescript-eslint/eslint-plugin'; +import tsParser from '@typescript-eslint/parser'; + +const eslintConfig = defineConfig([ + js.configs.recommended, + { + files: ['**/*.ts'], + languageOptions: { + parser: tsParser, + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + }, + globals: { + // Node.js globals + console: 'readonly', + process: 'readonly', + Buffer: 'readonly', + __dirname: 'readonly', + __filename: 'readonly', + URL: 'readonly', + URLSearchParams: 'readonly', + AbortController: 'readonly', + AbortSignal: 'readonly', + fetch: 'readonly', + Response: 'readonly', + Request: 'readonly', + Headers: 'readonly', + FormData: 'readonly', + RequestInit: 'readonly', + // Timers + setTimeout: 'readonly', + setInterval: 'readonly', + clearTimeout: 'readonly', + clearInterval: 'readonly', + setImmediate: 'readonly', + clearImmediate: 'readonly', + queueMicrotask: 'readonly', + // Node.js types + NodeJS: 'readonly', + }, + }, + plugins: { + '@typescript-eslint': ts, + }, + rules: { + ...ts.configs.recommended.rules, + '@typescript-eslint/no-unused-vars': [ + 'warn', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + ignoreRestSiblings: true, + }, + ], + '@typescript-eslint/no-explicit-any': 'warn', + // Server code frequently works with terminal output containing ANSI escape codes + 'no-control-regex': 'off', + '@typescript-eslint/ban-ts-comment': [ + 'error', + { + 'ts-nocheck': 'allow-with-description', + minimumDescriptionLength: 10, + }, + ], + }, + }, + globalIgnores(['dist/**', 'node_modules/**']), +]); + +export default eslintConfig; diff --git a/apps/server/src/routes/worktree/routes/checkout-branch.ts b/apps/server/src/routes/worktree/routes/checkout-branch.ts index ffa6e5e3..7ffee2c0 100644 --- a/apps/server/src/routes/worktree/routes/checkout-branch.ts +++ b/apps/server/src/routes/worktree/routes/checkout-branch.ts @@ -37,7 +37,7 @@ export function createCheckoutBranchHandler() { } // Validate branch name (basic validation) - const invalidChars = /[\s~^:?*\[\\]/; + const invalidChars = /[\s~^:?*[\\]/; if (invalidChars.test(branchName)) { res.status(400).json({ success: false, diff --git a/apps/server/src/routes/worktree/routes/open-in-editor.ts b/apps/server/src/routes/worktree/routes/open-in-editor.ts index c5ea6f9e..f0d620d4 100644 --- a/apps/server/src/routes/worktree/routes/open-in-editor.ts +++ b/apps/server/src/routes/worktree/routes/open-in-editor.ts @@ -125,19 +125,14 @@ export function createOpenInEditorHandler() { `Failed to open in editor, falling back to file manager: ${getErrorMessage(editorError)}` ); - try { - const result = await openInFileManager(worktreePath); - res.json({ - success: true, - result: { - message: `Opened ${worktreePath} in ${result.editorName}`, - editorName: result.editorName, - }, - }); - } catch (fallbackError) { - // Both editor and file manager failed - throw fallbackError; - } + const result = await openInFileManager(worktreePath); + res.json({ + success: true, + result: { + message: `Opened ${worktreePath} in ${result.editorName}`, + editorName: result.editorName, + }, + }); } } catch (error) { logError(error, 'Open in editor failed'); diff --git a/apps/server/src/services/claude-usage-service.ts b/apps/server/src/services/claude-usage-service.ts index 6438b5dc..40cffd7f 100644 --- a/apps/server/src/services/claude-usage-service.ts +++ b/apps/server/src/services/claude-usage-service.ts @@ -662,7 +662,7 @@ export class ClaudeUsageService { resetTime = this.parseResetTime(resetText, type); // Strip timezone like "(Asia/Dubai)" from the display text - resetText = resetText.replace(/\s*\([A-Za-z_\/]+\)\s*$/, '').trim(); + resetText = resetText.replace(/\s*\([A-Za-z_/]+\)\s*$/, '').trim(); } return { percentage: percentage ?? 0, resetTime, resetText }; diff --git a/apps/server/src/services/dev-server-service.ts b/apps/server/src/services/dev-server-service.ts index d81e539c..76cf3174 100644 --- a/apps/server/src/services/dev-server-service.ts +++ b/apps/server/src/services/dev-server-service.ts @@ -124,7 +124,7 @@ class DevServerService { /(?:Local|Network):\s+(https?:\/\/[^\s]+)/i, // Vite format /(?:ready|started server).*?(?:url:\s*)?(https?:\/\/[^\s,]+)/i, // Next.js format /(https?:\/\/(?:localhost|127\.0\.0\.1|\[::\]):\d+)/i, // Generic localhost URL - /(https?:\/\/[^\s<>"{}|\\^`\[\]]+)/i, // Any HTTP(S) URL + /(https?:\/\/[^\s<>"{}|\\^`[\]]+)/i, // Any HTTP(S) URL ]; for (const pattern of urlPatterns) { diff --git a/apps/server/src/services/ideation-service.ts b/apps/server/src/services/ideation-service.ts index efa32802..0d43252f 100644 --- a/apps/server/src/services/ideation-service.ts +++ b/apps/server/src/services/ideation-service.ts @@ -888,7 +888,7 @@ ${contextSection}${existingWorkSection}`; for (const line of lines) { // Check for numbered items or markdown headers - const titleMatch = line.match(/^(?:\d+[\.\)]\s*\*{0,2}|#{1,3}\s+)(.+)/); + const titleMatch = line.match(/^(?:\d+[.)]\s*\*{0,2}|#{1,3}\s+)(.+)/); if (titleMatch) { // Save previous suggestion diff --git a/apps/ui/eslint.config.mjs b/apps/ui/eslint.config.mjs index 3ad4d79d..2400404f 100644 --- a/apps/ui/eslint.config.mjs +++ b/apps/ui/eslint.config.mjs @@ -119,7 +119,15 @@ const eslintConfig = defineConfig([ }, rules: { ...ts.configs.recommended.rules, - '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], + '@typescript-eslint/no-unused-vars': [ + 'warn', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + ignoreRestSiblings: true, + }, + ], '@typescript-eslint/no-explicit-any': 'warn', '@typescript-eslint/ban-ts-comment': [ 'error', diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index 6f5f8e62..768b40a5 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -1545,7 +1545,8 @@ export function BoardView() { setSpawnParentFeature(feature); setShowAddDialog(true); }} - onDuplicate={handleDuplicateFeature} + onDuplicate={(feature) => handleDuplicateFeature(feature, false)} + onDuplicateAsChild={(feature) => handleDuplicateFeature(feature, true)} featuresWithContext={featuresWithContext} runningAutoTasks={runningAutoTasksAllWorktrees} onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)} diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx index d7760e01..d69ebf8e 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx @@ -31,6 +31,49 @@ import { formatModelName, DEFAULT_MODEL } from '@/lib/agent-context-parser'; import { DeleteConfirmDialog } from '@/components/ui/delete-confirm-dialog'; import { getProviderIconForModel } from '@/components/ui/provider-icon'; +function DuplicateMenuItems({ + onDuplicate, + onDuplicateAsChild, +}: { + onDuplicate?: () => void; + onDuplicateAsChild?: () => void; +}) { + if (!onDuplicate) return null; + return ( + +
+ { + e.stopPropagation(); + onDuplicate(); + }} + className="text-xs flex-1 pr-0 rounded-r-none" + > + + Duplicate + + {onDuplicateAsChild && ( + + )} +
+ {onDuplicateAsChild && ( + + { + e.stopPropagation(); + onDuplicateAsChild(); + }} + className="text-xs" + > + + Duplicate as Child + + + )} +
+ ); +} + interface CardHeaderProps { feature: Feature; isDraggable: boolean; @@ -122,39 +165,10 @@ export const CardHeaderSection = memo(function CardHeaderSection({ Spawn Sub-Task - {onDuplicate && ( - -
- { - e.stopPropagation(); - onDuplicate(); - }} - className="text-xs flex-1 pr-0 rounded-r-none" - > - - Duplicate - - {onDuplicateAsChild && ( - - )} -
- {onDuplicateAsChild && ( - - { - e.stopPropagation(); - onDuplicateAsChild(); - }} - className="text-xs" - > - - Duplicate as Child - - - )} -
- )} + {/* Model info in dropdown */} {(() => { const ProviderIcon = getProviderIconForModel(feature.model); @@ -217,39 +231,10 @@ export const CardHeaderSection = memo(function CardHeaderSection({ - {onDuplicate && ( - -
- { - e.stopPropagation(); - onDuplicate(); - }} - className="text-xs flex-1 pr-0 rounded-r-none" - > - - Duplicate - - {onDuplicateAsChild && ( - - )} -
- {onDuplicateAsChild && ( - - { - e.stopPropagation(); - onDuplicateAsChild(); - }} - className="text-xs" - > - - Duplicate as Child - - - )} -
- )} +
@@ -337,39 +322,10 @@ export const CardHeaderSection = memo(function CardHeaderSection({ Spawn Sub-Task - {onDuplicate && ( - -
- { - e.stopPropagation(); - onDuplicate(); - }} - className="text-xs flex-1 pr-0 rounded-r-none" - > - - Duplicate - - {onDuplicateAsChild && ( - - )} -
- {onDuplicateAsChild && ( - - { - e.stopPropagation(); - onDuplicateAsChild(); - }} - className="text-xs" - > - - Duplicate as Child - - - )} -
- )} +
@@ -440,39 +396,10 @@ export const CardHeaderSection = memo(function CardHeaderSection({ Spawn Sub-Task - {onDuplicate && ( - -
- { - e.stopPropagation(); - onDuplicate(); - }} - className="text-xs flex-1 pr-0 rounded-r-none" - > - - Duplicate - - {onDuplicateAsChild && ( - - )} -
- {onDuplicateAsChild && ( - - { - e.stopPropagation(); - onDuplicateAsChild(); - }} - className="text-xs" - > - - Duplicate as Child - - - )} -
- )} + {/* Model info in dropdown */} {(() => { const ProviderIcon = getProviderIconForModel(feature.model); diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-persistence.ts b/apps/ui/src/components/views/board-view/hooks/use-board-persistence.ts index d3004f74..143e9c3a 100644 --- a/apps/ui/src/components/views/board-view/hooks/use-board-persistence.ts +++ b/apps/ui/src/components/views/board-view/hooks/use-board-persistence.ts @@ -85,6 +85,11 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps throw new Error('Features API not available'); } + // Capture previous cache snapshot for synchronous rollback on error + const previousFeatures = queryClient.getQueryData( + queryKeys.features.all(currentProject.path) + ); + // Optimistically add to React Query cache for immediate board refresh queryClient.setQueryData( queryKeys.features.all(currentProject.path), @@ -95,6 +100,16 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps const result = await api.features.create(currentProject.path, feature as ApiFeature); if (result.success && result.feature) { updateFeature(result.feature.id, result.feature as Partial); + // Update cache with server-confirmed feature before invalidating + queryClient.setQueryData( + queryKeys.features.all(currentProject.path), + (features) => { + if (!features) return features; + return features.map((f) => + f.id === result.feature!.id ? { ...f, ...(result.feature as Feature) } : f + ); + } + ); } else if (!result.success) { throw new Error(result.error || 'Failed to create feature on server'); } @@ -104,7 +119,10 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps }); } catch (error) { logger.error('Failed to persist feature creation:', error); - // Rollback optimistic update on error + // Rollback optimistic update synchronously on error + if (previousFeatures) { + queryClient.setQueryData(queryKeys.features.all(currentProject.path), previousFeatures); + } queryClient.invalidateQueries({ queryKey: queryKeys.features.all(currentProject.path), }); @@ -131,7 +149,6 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps try { const api = getElectronAPI(); if (!api.features) { - logger.error('Features API not available'); // Rollback optimistic deletion since we can't persist if (previousFeatures) { queryClient.setQueryData(queryKeys.features.all(currentProject.path), previousFeatures); @@ -139,7 +156,7 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps queryClient.invalidateQueries({ queryKey: queryKeys.features.all(currentProject.path), }); - return; + throw new Error('Features API not available'); } await api.features.delete(currentProject.path, featureId); diff --git a/apps/ui/src/components/views/board-view/kanban-board.tsx b/apps/ui/src/components/views/board-view/kanban-board.tsx index ef091872..1a84080b 100644 --- a/apps/ui/src/components/views/board-view/kanban-board.tsx +++ b/apps/ui/src/components/views/board-view/kanban-board.tsx @@ -46,7 +46,8 @@ interface KanbanBoardProps { onViewPlan: (feature: Feature) => void; onApprovePlan: (feature: Feature) => void; onSpawnTask?: (feature: Feature) => void; - onDuplicate?: (feature: Feature, asChild: boolean) => void; + onDuplicate?: (feature: Feature) => void; + onDuplicateAsChild?: (feature: Feature) => void; featuresWithContext: Set; runningAutoTasks: string[]; onArchiveAllVerified: () => void; @@ -284,6 +285,7 @@ export function KanbanBoard({ onApprovePlan, onSpawnTask, onDuplicate, + onDuplicateAsChild, featuresWithContext, runningAutoTasks, onArchiveAllVerified, @@ -571,8 +573,8 @@ export function KanbanBoard({ onViewPlan={() => onViewPlan(feature)} onApprovePlan={() => onApprovePlan(feature)} onSpawnTask={() => onSpawnTask?.(feature)} - onDuplicate={() => onDuplicate?.(feature, false)} - onDuplicateAsChild={() => onDuplicate?.(feature, true)} + onDuplicate={() => onDuplicate?.(feature)} + onDuplicateAsChild={() => onDuplicateAsChild?.(feature)} hasContext={featuresWithContext.has(feature.id)} isCurrentAutoTask={runningAutoTasks.includes(feature.id)} shortcutKey={shortcutKey} @@ -615,8 +617,8 @@ export function KanbanBoard({ onViewPlan={() => onViewPlan(feature)} onApprovePlan={() => onApprovePlan(feature)} onSpawnTask={() => onSpawnTask?.(feature)} - onDuplicate={() => onDuplicate?.(feature, false)} - onDuplicateAsChild={() => onDuplicate?.(feature, true)} + onDuplicate={() => onDuplicate?.(feature)} + onDuplicateAsChild={() => onDuplicateAsChild?.(feature)} hasContext={featuresWithContext.has(feature.id)} isCurrentAutoTask={runningAutoTasks.includes(feature.id)} shortcutKey={shortcutKey} diff --git a/apps/ui/src/components/views/spec-view/components/edit-mode/features-section.tsx b/apps/ui/src/components/views/spec-view/components/edit-mode/features-section.tsx index b27ec3e4..ad82a4d7 100644 --- a/apps/ui/src/components/views/spec-view/components/edit-mode/features-section.tsx +++ b/apps/ui/src/components/views/spec-view/components/edit-mode/features-section.tsx @@ -32,7 +32,6 @@ function featureToInternal(feature: Feature): FeatureWithId { } function internalToFeature(internal: FeatureWithId): Feature { - // eslint-disable-next-line @typescript-eslint/no-unused-vars const { _id, _locationIds, ...feature } = internal; return feature; } diff --git a/apps/ui/src/components/views/spec-view/components/edit-mode/roadmap-section.tsx b/apps/ui/src/components/views/spec-view/components/edit-mode/roadmap-section.tsx index c5d6ddd4..b13f35e7 100644 --- a/apps/ui/src/components/views/spec-view/components/edit-mode/roadmap-section.tsx +++ b/apps/ui/src/components/views/spec-view/components/edit-mode/roadmap-section.tsx @@ -27,7 +27,6 @@ function phaseToInternal(phase: RoadmapPhase): PhaseWithId { } function internalToPhase(internal: PhaseWithId): RoadmapPhase { - // eslint-disable-next-line @typescript-eslint/no-unused-vars const { _id, ...phase } = internal; return phase; } diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index 22079822..446b7b6f 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -1062,7 +1062,6 @@ if (typeof window !== 'undefined') { } // Mock API for development/fallback when no backend is available -// eslint-disable-next-line @typescript-eslint/no-unused-vars const _getMockElectronAPI = (): ElectronAPI => { return { ping: async () => 'pong (mock)', diff --git a/apps/ui/src/store/test-runners-store.ts b/apps/ui/src/store/test-runners-store.ts index b763c15a..8f8f7984 100644 --- a/apps/ui/src/store/test-runners-store.ts +++ b/apps/ui/src/store/test-runners-store.ts @@ -155,7 +155,6 @@ export const useTestRunnersStore = create const finishedAt = new Date().toISOString(); // Remove from active sessions since it's no longer running - // eslint-disable-next-line @typescript-eslint/no-unused-vars const { [session.worktreePath]: _, ...remainingActive } = state.activeSessionByWorktree; return { @@ -202,7 +201,6 @@ export const useTestRunnersStore = create const session = state.sessions[sessionId]; if (!session) return state; - // eslint-disable-next-line @typescript-eslint/no-unused-vars const { [sessionId]: _, ...remainingSessions } = state.sessions; // Remove from active if this was the active session @@ -231,7 +229,6 @@ export const useTestRunnersStore = create }); // Remove from active - // eslint-disable-next-line @typescript-eslint/no-unused-vars const { [worktreePath]: _, ...remainingActive } = state.activeSessionByWorktree; return { From b5ad77b0f9c74438e5e698d4542257f390422f3a Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 17 Feb 2026 09:56:54 -0800 Subject: [PATCH 099/156] feat: Add feature state reconciliation on server startup --- apps/server/src/index.ts | 24 +++ apps/server/src/routes/auto-mode/index.ts | 6 + .../src/routes/auto-mode/routes/reconcile.ts | 53 ++++++ apps/server/src/services/auto-mode/compat.ts | 4 + .../src/services/auto-mode/global-service.ts | 17 ++ .../src/services/feature-state-manager.ts | 151 ++++++++++++++++++ apps/server/src/services/typed-event-bus.ts | 2 + apps/ui/src/hooks/use-query-invalidation.ts | 2 + apps/ui/src/types/electron.d.ts | 15 ++ 9 files changed, 274 insertions(+) create mode 100644 apps/server/src/routes/auto-mode/routes/reconcile.ts diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index a78e9e83..54fcc247 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -386,6 +386,30 @@ eventHookService.initialize(events, settingsService, eventHistoryService, featur await agentService.initialize(); logger.info('Agent service initialized'); + // Reconcile feature states on startup + // After any type of restart (clean, forced, crash), features may be stuck in + // transient states (in_progress, interrupted, pipeline_*) that don't match reality. + // Reconcile them back to resting states before the UI is served. + try { + const settings = await settingsService.getGlobalSettings(); + if (settings.projects && settings.projects.length > 0) { + let totalReconciled = 0; + for (const project of settings.projects) { + const count = await autoModeService.reconcileFeatureStates(project.path); + totalReconciled += count; + } + if (totalReconciled > 0) { + logger.info( + `[STARTUP] Reconciled ${totalReconciled} feature(s) across ${settings.projects.length} project(s)` + ); + } else { + logger.info('[STARTUP] Feature state reconciliation complete - no stale states found'); + } + } + } catch (err) { + logger.warn('[STARTUP] Failed to reconcile feature states:', err); + } + // Bootstrap Codex model cache in background (don't block server startup) void codexModelCacheService.getModels().catch((err) => { logger.error('Failed to bootstrap Codex model cache:', err); diff --git a/apps/server/src/routes/auto-mode/index.ts b/apps/server/src/routes/auto-mode/index.ts index a0c998d6..016447d7 100644 --- a/apps/server/src/routes/auto-mode/index.ts +++ b/apps/server/src/routes/auto-mode/index.ts @@ -21,6 +21,7 @@ import { createFollowUpFeatureHandler } from './routes/follow-up-feature.js'; import { createCommitFeatureHandler } from './routes/commit-feature.js'; import { createApprovePlanHandler } from './routes/approve-plan.js'; import { createResumeInterruptedHandler } from './routes/resume-interrupted.js'; +import { createReconcileHandler } from './routes/reconcile.js'; /** * Create auto-mode routes. @@ -81,6 +82,11 @@ export function createAutoModeRoutes(autoModeService: AutoModeServiceCompat): Ro validatePathParams('projectPath'), createResumeInterruptedHandler(autoModeService) ); + router.post( + '/reconcile', + validatePathParams('projectPath'), + createReconcileHandler(autoModeService) + ); return router; } diff --git a/apps/server/src/routes/auto-mode/routes/reconcile.ts b/apps/server/src/routes/auto-mode/routes/reconcile.ts new file mode 100644 index 00000000..96109051 --- /dev/null +++ b/apps/server/src/routes/auto-mode/routes/reconcile.ts @@ -0,0 +1,53 @@ +/** + * Reconcile Feature States Handler + * + * On-demand endpoint to reconcile all feature states for a project. + * Resets features stuck in transient states (in_progress, interrupted, pipeline_*) + * back to resting states (ready/backlog) and emits events to update the UI. + * + * This is useful when: + * - The UI reconnects after a server restart + * - A client detects stale feature states + * - An admin wants to force-reset stuck features + */ + +import type { Request, Response } from 'express'; +import { createLogger } from '@automaker/utils'; +import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js'; + +const logger = createLogger('ReconcileFeatures'); + +interface ReconcileRequest { + projectPath: string; +} + +export function createReconcileHandler(autoModeService: AutoModeServiceCompat) { + return async (req: Request, res: Response): Promise => { + const { projectPath } = req.body as ReconcileRequest; + + if (!projectPath) { + res.status(400).json({ error: 'Project path is required' }); + return; + } + + logger.info(`Reconciling feature states for ${projectPath}`); + + try { + const reconciledCount = await autoModeService.reconcileFeatureStates(projectPath); + + res.json({ + success: true, + reconciledCount, + message: + reconciledCount > 0 + ? `Reconciled ${reconciledCount} feature(s)` + : 'No features needed reconciliation', + }); + } catch (error) { + logger.error('Error reconciling feature states:', error); + res.status(500).json({ + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + }; +} diff --git a/apps/server/src/services/auto-mode/compat.ts b/apps/server/src/services/auto-mode/compat.ts index 2c713c01..97fe19e8 100644 --- a/apps/server/src/services/auto-mode/compat.ts +++ b/apps/server/src/services/auto-mode/compat.ts @@ -88,6 +88,10 @@ export class AutoModeServiceCompat { return this.globalService.markAllRunningFeaturesInterrupted(reason); } + async reconcileFeatureStates(projectPath: string): Promise { + return this.globalService.reconcileFeatureStates(projectPath); + } + // =========================================================================== // PER-PROJECT OPERATIONS (delegated to facades) // =========================================================================== diff --git a/apps/server/src/services/auto-mode/global-service.ts b/apps/server/src/services/auto-mode/global-service.ts index 0e0e7e52..90576f8c 100644 --- a/apps/server/src/services/auto-mode/global-service.ts +++ b/apps/server/src/services/auto-mode/global-service.ts @@ -205,4 +205,21 @@ export class GlobalAutoModeService { ); } } + + /** + * Reconcile all feature states for a project on server startup. + * + * Resets features stuck in transient states (in_progress, interrupted, pipeline_*) + * back to a resting state and emits events so the UI reflects corrected states. + * + * This should be called during server initialization to handle: + * - Clean shutdown: features already marked as interrupted + * - Forced kill / crash: features left in in_progress or pipeline_* states + * + * @param projectPath - The project path to reconcile + * @returns The number of features that were reconciled + */ + async reconcileFeatureStates(projectPath: string): Promise { + return this.featureStateManager.reconcileAllFeatureStates(projectPath); + } } diff --git a/apps/server/src/services/feature-state-manager.ts b/apps/server/src/services/feature-state-manager.ts index b33f6df6..4ccc5e5c 100644 --- a/apps/server/src/services/feature-state-manager.ts +++ b/apps/server/src/services/feature-state-manager.ts @@ -273,6 +273,8 @@ export class FeatureStateManager { * * Resets: * - in_progress features back to ready (if has plan) or backlog (if no plan) + * - interrupted features back to ready (if has plan) or backlog (if no plan) + * - pipeline_* features back to ready (if has plan) or backlog (if no plan) * - generating planSpec status back to pending * - in_progress tasks back to pending * @@ -300,6 +302,7 @@ export class FeatureStateManager { if (!feature) continue; let needsUpdate = false; + const originalStatus = feature.status; // Reset in_progress features back to ready/backlog if (feature.status === 'in_progress') { @@ -311,6 +314,30 @@ export class FeatureStateManager { ); } + // Reset interrupted features back to ready/backlog + // These were marked interrupted during graceful shutdown but need to be reset + // so they appear in the correct column and can be re-executed + if (feature.status === 'interrupted') { + const hasApprovedPlan = feature.planSpec?.status === 'approved'; + feature.status = hasApprovedPlan ? 'ready' : 'backlog'; + needsUpdate = true; + logger.info( + `[resetStuckFeatures] Reset feature ${feature.id} from interrupted to ${feature.status}` + ); + } + + // Reset pipeline_* features back to ready/backlog + // After a server restart, pipeline execution cannot resume from the exact step, + // so these need to be reset to a clean state for re-execution + if (feature.status && feature.status.startsWith('pipeline_')) { + const hasApprovedPlan = feature.planSpec?.status === 'approved'; + feature.status = hasApprovedPlan ? 'ready' : 'backlog'; + needsUpdate = true; + logger.info( + `[resetStuckFeatures] Reset feature ${feature.id} from ${originalStatus} to ${feature.status}` + ); + } + // Reset generating planSpec status back to pending (spec generation was interrupted) if (feature.planSpec?.status === 'generating') { feature.planSpec.status = 'pending'; @@ -358,6 +385,130 @@ export class FeatureStateManager { } } + /** + * Reconcile all feature states on server startup. + * + * This method resets all features stuck in transient states (in_progress, + * interrupted, pipeline_*) and emits events so connected UI clients + * immediately reflect the corrected states. + * + * Should be called once during server initialization, before the UI is served, + * to ensure feature state consistency after any type of restart (clean, forced, crash). + * + * @param projectPath - The project path to reconcile features for + * @returns The number of features that were reconciled + */ + async reconcileAllFeatureStates(projectPath: string): Promise { + logger.info(`[reconcileAllFeatureStates] Starting reconciliation for ${projectPath}`); + + const featuresDir = getFeaturesDir(projectPath); + let featuresScanned = 0; + let featuresReconciled = 0; + const reconciledFeatureIds: string[] = []; + + try { + const entries = await secureFs.readdir(featuresDir, { withFileTypes: true }); + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + + featuresScanned++; + const featurePath = path.join(featuresDir, entry.name, 'feature.json'); + const result = await readJsonWithRecovery(featurePath, null, { + maxBackups: DEFAULT_BACKUP_COUNT, + autoRestore: true, + }); + + const feature = result.data; + if (!feature) continue; + + let needsUpdate = false; + const originalStatus = feature.status; + + // Reset features in active execution states back to a resting state + // After a server restart, no processes are actually running + const isActiveState = + feature.status === 'in_progress' || + feature.status === 'interrupted' || + (feature.status && feature.status.startsWith('pipeline_')); + + if (isActiveState) { + const hasApprovedPlan = feature.planSpec?.status === 'approved'; + feature.status = hasApprovedPlan ? 'ready' : 'backlog'; + needsUpdate = true; + logger.info( + `[reconcileAllFeatureStates] Reset feature ${feature.id} from ${originalStatus} to ${feature.status}` + ); + } + + // Reset generating planSpec status back to pending + if (feature.planSpec?.status === 'generating') { + feature.planSpec.status = 'pending'; + needsUpdate = true; + logger.info( + `[reconcileAllFeatureStates] Reset feature ${feature.id} planSpec from generating to pending` + ); + } + + // Reset any in_progress tasks back to pending + if (feature.planSpec?.tasks) { + for (const task of feature.planSpec.tasks) { + if (task.status === 'in_progress') { + task.status = 'pending'; + needsUpdate = true; + logger.info( + `[reconcileAllFeatureStates] Reset task ${task.id} for feature ${feature.id} from in_progress to pending` + ); + if (feature.planSpec?.currentTaskId === task.id) { + feature.planSpec.currentTaskId = undefined; + } + } + } + } + + if (needsUpdate) { + feature.updatedAt = new Date().toISOString(); + await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT }); + featuresReconciled++; + reconciledFeatureIds.push(feature.id); + + // Emit per-feature status change event so UI invalidates its cache + this.emitAutoModeEvent('feature_status_changed', { + featureId: feature.id, + projectPath, + status: feature.status, + previousStatus: originalStatus, + reason: 'server_restart_reconciliation', + }); + } + } + + // Emit a bulk reconciliation event for the UI + if (featuresReconciled > 0) { + this.emitAutoModeEvent('features_reconciled', { + projectPath, + reconciledCount: featuresReconciled, + reconciledFeatureIds, + message: `Reconciled ${featuresReconciled} feature(s) after server restart`, + }); + } + + logger.info( + `[reconcileAllFeatureStates] Scanned ${featuresScanned} features, reconciled ${featuresReconciled} for ${projectPath}` + ); + + return featuresReconciled; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + logger.error( + `[reconcileAllFeatureStates] Error reconciling features for ${projectPath}:`, + error + ); + } + return 0; + } + } + /** * Update the planSpec of a feature with partial updates. * diff --git a/apps/server/src/services/typed-event-bus.ts b/apps/server/src/services/typed-event-bus.ts index 11424826..42600db5 100644 --- a/apps/server/src/services/typed-event-bus.ts +++ b/apps/server/src/services/typed-event-bus.ts @@ -42,6 +42,8 @@ export type AutoModeEventType = | 'plan_revision_warning' | 'pipeline_step_started' | 'pipeline_step_complete' + | 'feature_status_changed' + | 'features_reconciled' | string; // Allow other strings for extensibility /** diff --git a/apps/ui/src/hooks/use-query-invalidation.ts b/apps/ui/src/hooks/use-query-invalidation.ts index 0c09977c..241538e3 100644 --- a/apps/ui/src/hooks/use-query-invalidation.ts +++ b/apps/ui/src/hooks/use-query-invalidation.ts @@ -38,6 +38,8 @@ const FEATURE_LIST_INVALIDATION_EVENTS: AutoModeEvent['type'][] = [ 'plan_rejected', 'pipeline_step_started', 'pipeline_step_complete', + 'feature_status_changed', + 'features_reconciled', ]; /** diff --git a/apps/ui/src/types/electron.d.ts b/apps/ui/src/types/electron.d.ts index cf41dabe..e44f19dd 100644 --- a/apps/ui/src/types/electron.d.ts +++ b/apps/ui/src/types/electron.d.ts @@ -359,6 +359,21 @@ export type AutoModeEvent = title?: string; status?: string; }>; + } + | { + type: 'feature_status_changed'; + featureId: string; + projectPath?: string; + status: string; + previousStatus: string; + reason?: string; + } + | { + type: 'features_reconciled'; + projectPath?: string; + reconciledCount: number; + reconciledFeatureIds: string[]; + message: string; }; export type SpecRegenerationEvent = From f7b3f75163ba8f65bb5c16257000d9dfb4f70fd1 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 17 Feb 2026 10:17:23 -0800 Subject: [PATCH 100/156] feat: Add path validation and security improvements to worktree routes --- apps/server/src/routes/worktree/index.ts | 7 +- .../routes/worktree/routes/checkout-branch.ts | 60 ++++++---- .../components/kanban-card/card-header.tsx | 103 +++++++++++------- 3 files changed, 106 insertions(+), 64 deletions(-) diff --git a/apps/server/src/routes/worktree/index.ts b/apps/server/src/routes/worktree/index.ts index 992a7b48..a7df37bb 100644 --- a/apps/server/src/routes/worktree/index.ts +++ b/apps/server/src/routes/worktree/index.ts @@ -101,7 +101,12 @@ export function createWorktreeRoutes( requireValidWorktree, createPullHandler() ); - router.post('/checkout-branch', requireValidWorktree, createCheckoutBranchHandler()); + router.post( + '/checkout-branch', + validatePathParams('worktreePath'), + requireValidWorktree, + createCheckoutBranchHandler() + ); router.post( '/list-branches', validatePathParams('worktreePath'), diff --git a/apps/server/src/routes/worktree/routes/checkout-branch.ts b/apps/server/src/routes/worktree/routes/checkout-branch.ts index 7ffee2c0..23963480 100644 --- a/apps/server/src/routes/worktree/routes/checkout-branch.ts +++ b/apps/server/src/routes/worktree/routes/checkout-branch.ts @@ -2,15 +2,15 @@ * POST /checkout-branch endpoint - Create and checkout a new branch * * Note: Git repository validation (isGitRepo, hasCommits) is handled by - * the requireValidWorktree middleware in index.ts + * the requireValidWorktree middleware in index.ts. + * Path validation (ALLOWED_ROOT_DIRECTORY) is handled by validatePathParams + * middleware in index.ts. */ import type { Request, Response } from 'express'; -import { exec } from 'child_process'; -import { promisify } from 'util'; -import { getErrorMessage, logError } from '../common.js'; - -const execAsync = promisify(exec); +import path from 'path'; +import { stat } from 'fs/promises'; +import { getErrorMessage, logError, isValidBranchName, execGitCommand } from '../common.js'; export function createCheckoutBranchHandler() { return async (req: Request, res: Response): Promise => { @@ -36,27 +36,47 @@ export function createCheckoutBranchHandler() { return; } - // Validate branch name (basic validation) - const invalidChars = /[\s~^:?*[\\]/; - if (invalidChars.test(branchName)) { + // Validate branch name using shared allowlist: /^[a-zA-Z0-9._\-/]+$/ + if (!isValidBranchName(branchName)) { res.status(400).json({ success: false, - error: 'Branch name contains invalid characters', + error: + 'Invalid branch name. Must contain only letters, numbers, dots, dashes, underscores, or slashes.', }); return; } - // Get current branch for reference - const { stdout: currentBranchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', { - cwd: worktreePath, - }); + // Resolve and validate worktreePath to prevent traversal attacks. + // The validatePathParams middleware checks against ALLOWED_ROOT_DIRECTORY, + // but we also resolve the path and verify it exists as a directory. + const resolvedPath = path.resolve(worktreePath); + try { + const stats = await stat(resolvedPath); + if (!stats.isDirectory()) { + res.status(400).json({ + success: false, + error: 'worktreePath is not a directory', + }); + return; + } + } catch { + res.status(400).json({ + success: false, + error: 'worktreePath does not exist or is not accessible', + }); + return; + } + + // Get current branch for reference (using argument array to avoid shell injection) + const currentBranchOutput = await execGitCommand( + ['rev-parse', '--abbrev-ref', 'HEAD'], + resolvedPath + ); const currentBranch = currentBranchOutput.trim(); // Check if branch already exists try { - await execAsync(`git rev-parse --verify ${branchName}`, { - cwd: worktreePath, - }); + await execGitCommand(['rev-parse', '--verify', branchName], resolvedPath); // Branch exists res.status(400).json({ success: false, @@ -67,10 +87,8 @@ export function createCheckoutBranchHandler() { // Branch doesn't exist, good to create } - // Create and checkout the new branch - await execAsync(`git checkout -b ${branchName}`, { - cwd: worktreePath, - }); + // Create and checkout the new branch (using argument array to avoid shell injection) + await execGitCommand(['checkout', '-b', branchName], resolvedPath); res.json({ success: true, diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx index d69ebf8e..0f44ac21 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx @@ -1,4 +1,3 @@ -// @ts-nocheck - header component props with optional handlers and status variants import { memo, useState } from 'react'; import type { DraggableAttributes, DraggableSyntheticListeners } from '@dnd-kit/core'; import { Feature } from '@/store/app-store'; @@ -39,37 +38,53 @@ function DuplicateMenuItems({ onDuplicateAsChild?: () => void; }) { if (!onDuplicate) return null; + + // When there's no sub-child action, render a simple menu item (no DropdownMenuSub wrapper) + if (!onDuplicateAsChild) { + return ( + { + e.stopPropagation(); + onDuplicate(); + }} + className="text-xs" + > + + Duplicate + + ); + } + + // When sub-child action is available, render a proper DropdownMenuSub with + // DropdownMenuSubTrigger and DropdownMenuSubContent per Radix conventions return ( -
+ + + Duplicate + + { e.stopPropagation(); onDuplicate(); }} - className="text-xs flex-1 pr-0 rounded-r-none" + className="text-xs" > Duplicate - {onDuplicateAsChild && ( - - )} -
- {onDuplicateAsChild && ( - - { - e.stopPropagation(); - onDuplicateAsChild(); - }} - className="text-xs" - > - - Duplicate as Child - - - )} + { + e.stopPropagation(); + onDuplicateAsChild(); + }} + className="text-xs" + > + + Duplicate as Child + +
); } @@ -122,7 +137,7 @@ export const CardHeaderSection = memo(function CardHeaderSection({
- {feature.startedAt && ( + {typeof feature.startedAt === 'string' && ( - - - - - - - - + {/* Only render overflow menu when there are actionable items */} + {onDuplicate && ( + + + + + + + + + )}
)} From dee770c2ab60b59ffee547d4ac1e7d20bb28b6a1 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 17 Feb 2026 10:32:20 -0800 Subject: [PATCH 101/156] refactor: Consolidate global settings fetching to avoid duplicate calls --- apps/server/src/index.ts | 67 ++-- .../src/services/feature-state-manager.ts | 363 ++++++++---------- apps/server/src/services/typed-event-bus.ts | 6 +- apps/ui/src/types/electron.d.ts | 6 +- libs/types/src/pipeline.ts | 1 + start-automaker.sh | 27 +- 6 files changed, 230 insertions(+), 240 deletions(-) diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 54fcc247..a7ad979d 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -368,19 +368,31 @@ eventHookService.initialize(events, settingsService, eventHistoryService, featur logger.warn('Failed to check for legacy settings migration:', err); } - // Apply logging settings from saved settings + // Fetch global settings once and reuse for logging config and feature reconciliation + let globalSettings: Awaited> | null = null; try { - const settings = await settingsService.getGlobalSettings(); - if (settings.serverLogLevel && LOG_LEVEL_MAP[settings.serverLogLevel] !== undefined) { - setLogLevel(LOG_LEVEL_MAP[settings.serverLogLevel]); - logger.info(`Server log level set to: ${settings.serverLogLevel}`); - } - // Apply request logging setting (default true if not set) - const enableRequestLog = settings.enableRequestLogging ?? true; - setRequestLoggingEnabled(enableRequestLog); - logger.info(`HTTP request logging: ${enableRequestLog ? 'enabled' : 'disabled'}`); + globalSettings = await settingsService.getGlobalSettings(); } catch (err) { - logger.warn('Failed to load logging settings, using defaults'); + logger.warn('Failed to load global settings, using defaults'); + } + + // Apply logging settings from saved settings + if (globalSettings) { + try { + if ( + globalSettings.serverLogLevel && + LOG_LEVEL_MAP[globalSettings.serverLogLevel] !== undefined + ) { + setLogLevel(LOG_LEVEL_MAP[globalSettings.serverLogLevel]); + logger.info(`Server log level set to: ${globalSettings.serverLogLevel}`); + } + // Apply request logging setting (default true if not set) + const enableRequestLog = globalSettings.enableRequestLogging ?? true; + setRequestLoggingEnabled(enableRequestLog); + logger.info(`HTTP request logging: ${enableRequestLog ? 'enabled' : 'disabled'}`); + } catch (err) { + logger.warn('Failed to apply logging settings, using defaults'); + } } await agentService.initialize(); @@ -390,24 +402,25 @@ eventHookService.initialize(events, settingsService, eventHistoryService, featur // After any type of restart (clean, forced, crash), features may be stuck in // transient states (in_progress, interrupted, pipeline_*) that don't match reality. // Reconcile them back to resting states before the UI is served. - try { - const settings = await settingsService.getGlobalSettings(); - if (settings.projects && settings.projects.length > 0) { - let totalReconciled = 0; - for (const project of settings.projects) { - const count = await autoModeService.reconcileFeatureStates(project.path); - totalReconciled += count; - } - if (totalReconciled > 0) { - logger.info( - `[STARTUP] Reconciled ${totalReconciled} feature(s) across ${settings.projects.length} project(s)` - ); - } else { - logger.info('[STARTUP] Feature state reconciliation complete - no stale states found'); + if (globalSettings) { + try { + if (globalSettings.projects && globalSettings.projects.length > 0) { + let totalReconciled = 0; + for (const project of globalSettings.projects) { + const count = await autoModeService.reconcileFeatureStates(project.path); + totalReconciled += count; + } + if (totalReconciled > 0) { + logger.info( + `[STARTUP] Reconciled ${totalReconciled} feature(s) across ${globalSettings.projects.length} project(s)` + ); + } else { + logger.info('[STARTUP] Feature state reconciliation complete - no stale states found'); + } } + } catch (err) { + logger.warn('[STARTUP] Failed to reconcile feature states:', err); } - } catch (err) { - logger.warn('[STARTUP] Failed to reconcile feature states:', err); } // Bootstrap Codex model cache in background (don't block server startup) diff --git a/apps/server/src/services/feature-state-manager.ts b/apps/server/src/services/feature-state-manager.ts index 4ccc5e5c..1f8a4952 100644 --- a/apps/server/src/services/feature-state-manager.ts +++ b/apps/server/src/services/feature-state-manager.ts @@ -25,6 +25,7 @@ import { import { getFeatureDir, getFeaturesDir } from '@automaker/platform'; import * as secureFs from '../lib/secure-fs.js'; import type { EventEmitter } from '../lib/events.js'; +import type { AutoModeEventType } from './typed-event-bus.js'; import { getNotificationService } from './notification-service.js'; import { FeatureLoader } from './feature-loader.js'; @@ -267,6 +268,127 @@ export class FeatureStateManager { await this.updateFeatureStatus(projectPath, featureId, 'interrupted'); } + /** + * Shared helper that scans features in a project directory and resets any stuck + * in transient states (in_progress, interrupted, pipeline_*) back to resting states. + * + * Also resets: + * - generating planSpec status back to pending + * - in_progress tasks back to pending + * + * @param projectPath - The project path to scan + * @param callerLabel - Label for log messages (e.g., 'resetStuckFeatures', 'reconcileAllFeatureStates') + * @returns Object with reconciledFeatures (id + status info), reconciledCount, and scanned count + */ + private async scanAndResetFeatures( + projectPath: string, + callerLabel: string + ): Promise<{ + reconciledFeatures: Array<{ + id: string; + previousStatus: string | undefined; + newStatus: string | undefined; + }>; + reconciledFeatureIds: string[]; + reconciledCount: number; + scanned: number; + }> { + const featuresDir = getFeaturesDir(projectPath); + let scanned = 0; + let reconciledCount = 0; + const reconciledFeatureIds: string[] = []; + const reconciledFeatures: Array<{ + id: string; + previousStatus: string | undefined; + newStatus: string | undefined; + }> = []; + + try { + const entries = await secureFs.readdir(featuresDir, { withFileTypes: true }); + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + + scanned++; + const featurePath = path.join(featuresDir, entry.name, 'feature.json'); + const result = await readJsonWithRecovery(featurePath, null, { + maxBackups: DEFAULT_BACKUP_COUNT, + autoRestore: true, + }); + + const feature = result.data; + if (!feature) continue; + + let needsUpdate = false; + const originalStatus = feature.status; + + // Reset features in active execution states back to a resting state + // After a server restart, no processes are actually running + const isActiveState = + originalStatus === 'in_progress' || + originalStatus === 'interrupted' || + (originalStatus != null && originalStatus.startsWith('pipeline_')); + + if (isActiveState) { + const hasApprovedPlan = feature.planSpec?.status === 'approved'; + feature.status = hasApprovedPlan ? 'ready' : 'backlog'; + needsUpdate = true; + logger.info( + `[${callerLabel}] Reset feature ${feature.id} from ${originalStatus} to ${feature.status}` + ); + } + + // Reset generating planSpec status back to pending (spec generation was interrupted) + if (feature.planSpec?.status === 'generating') { + feature.planSpec.status = 'pending'; + needsUpdate = true; + logger.info( + `[${callerLabel}] Reset feature ${feature.id} planSpec status from generating to pending` + ); + } + + // Reset any in_progress tasks back to pending (task execution was interrupted) + if (feature.planSpec?.tasks) { + for (const task of feature.planSpec.tasks) { + if (task.status === 'in_progress') { + task.status = 'pending'; + needsUpdate = true; + logger.info( + `[${callerLabel}] Reset task ${task.id} for feature ${feature.id} from in_progress to pending` + ); + // Clear currentTaskId if it points to this reverted task + if (feature.planSpec?.currentTaskId === task.id) { + feature.planSpec.currentTaskId = undefined; + logger.info( + `[${callerLabel}] Cleared planSpec.currentTaskId for feature ${feature.id} (was pointing to reverted task ${task.id})` + ); + } + } + } + } + + if (needsUpdate) { + feature.updatedAt = new Date().toISOString(); + await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT }); + reconciledCount++; + reconciledFeatureIds.push(feature.id); + reconciledFeatures.push({ + id: feature.id, + previousStatus: originalStatus, + newStatus: feature.status, + }); + } + } + } catch (error) { + // If features directory doesn't exist, that's fine + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + logger.error(`[${callerLabel}] Error resetting features for ${projectPath}:`, error); + } + } + + return { reconciledFeatures, reconciledFeatureIds, reconciledCount, scanned }; + } + /** * Reset features that were stuck in transient states due to server crash. * Called when auto mode is enabled to clean up from previous session. @@ -281,108 +403,14 @@ export class FeatureStateManager { * @param projectPath - The project path to reset features for */ async resetStuckFeatures(projectPath: string): Promise { - const featuresDir = getFeaturesDir(projectPath); - let featuresScanned = 0; - let featuresReset = 0; + const { reconciledCount, scanned } = await this.scanAndResetFeatures( + projectPath, + 'resetStuckFeatures' + ); - try { - const entries = await secureFs.readdir(featuresDir, { withFileTypes: true }); - - for (const entry of entries) { - if (!entry.isDirectory()) continue; - - featuresScanned++; - const featurePath = path.join(featuresDir, entry.name, 'feature.json'); - const result = await readJsonWithRecovery(featurePath, null, { - maxBackups: DEFAULT_BACKUP_COUNT, - autoRestore: true, - }); - - const feature = result.data; - if (!feature) continue; - - let needsUpdate = false; - const originalStatus = feature.status; - - // Reset in_progress features back to ready/backlog - if (feature.status === 'in_progress') { - const hasApprovedPlan = feature.planSpec?.status === 'approved'; - feature.status = hasApprovedPlan ? 'ready' : 'backlog'; - needsUpdate = true; - logger.info( - `[resetStuckFeatures] Reset feature ${feature.id} from in_progress to ${feature.status}` - ); - } - - // Reset interrupted features back to ready/backlog - // These were marked interrupted during graceful shutdown but need to be reset - // so they appear in the correct column and can be re-executed - if (feature.status === 'interrupted') { - const hasApprovedPlan = feature.planSpec?.status === 'approved'; - feature.status = hasApprovedPlan ? 'ready' : 'backlog'; - needsUpdate = true; - logger.info( - `[resetStuckFeatures] Reset feature ${feature.id} from interrupted to ${feature.status}` - ); - } - - // Reset pipeline_* features back to ready/backlog - // After a server restart, pipeline execution cannot resume from the exact step, - // so these need to be reset to a clean state for re-execution - if (feature.status && feature.status.startsWith('pipeline_')) { - const hasApprovedPlan = feature.planSpec?.status === 'approved'; - feature.status = hasApprovedPlan ? 'ready' : 'backlog'; - needsUpdate = true; - logger.info( - `[resetStuckFeatures] Reset feature ${feature.id} from ${originalStatus} to ${feature.status}` - ); - } - - // Reset generating planSpec status back to pending (spec generation was interrupted) - if (feature.planSpec?.status === 'generating') { - feature.planSpec.status = 'pending'; - needsUpdate = true; - logger.info( - `[resetStuckFeatures] Reset feature ${feature.id} planSpec status from generating to pending` - ); - } - - // Reset any in_progress tasks back to pending (task execution was interrupted) - if (feature.planSpec?.tasks) { - for (const task of feature.planSpec.tasks) { - if (task.status === 'in_progress') { - task.status = 'pending'; - needsUpdate = true; - logger.info( - `[resetStuckFeatures] Reset task ${task.id} for feature ${feature.id} from in_progress to pending` - ); - // Clear currentTaskId if it points to this reverted task - if (feature.planSpec?.currentTaskId === task.id) { - feature.planSpec.currentTaskId = undefined; - logger.info( - `[resetStuckFeatures] Cleared planSpec.currentTaskId for feature ${feature.id} (was pointing to reverted task ${task.id})` - ); - } - } - } - } - - if (needsUpdate) { - feature.updatedAt = new Date().toISOString(); - await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT }); - featuresReset++; - } - } - - logger.info( - `[resetStuckFeatures] Scanned ${featuresScanned} features, reset ${featuresReset} features for ${projectPath}` - ); - } catch (error) { - // If features directory doesn't exist, that's fine - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - logger.error(`[resetStuckFeatures] Error resetting features for ${projectPath}:`, error); - } - } + logger.info( + `[resetStuckFeatures] Scanned ${scanned} features, reset ${reconciledCount} features for ${projectPath}` + ); } /** @@ -401,112 +429,35 @@ export class FeatureStateManager { async reconcileAllFeatureStates(projectPath: string): Promise { logger.info(`[reconcileAllFeatureStates] Starting reconciliation for ${projectPath}`); - const featuresDir = getFeaturesDir(projectPath); - let featuresScanned = 0; - let featuresReconciled = 0; - const reconciledFeatureIds: string[] = []; + const { reconciledFeatures, reconciledFeatureIds, reconciledCount, scanned } = + await this.scanAndResetFeatures(projectPath, 'reconcileAllFeatureStates'); - try { - const entries = await secureFs.readdir(featuresDir, { withFileTypes: true }); - - for (const entry of entries) { - if (!entry.isDirectory()) continue; - - featuresScanned++; - const featurePath = path.join(featuresDir, entry.name, 'feature.json'); - const result = await readJsonWithRecovery(featurePath, null, { - maxBackups: DEFAULT_BACKUP_COUNT, - autoRestore: true, - }); - - const feature = result.data; - if (!feature) continue; - - let needsUpdate = false; - const originalStatus = feature.status; - - // Reset features in active execution states back to a resting state - // After a server restart, no processes are actually running - const isActiveState = - feature.status === 'in_progress' || - feature.status === 'interrupted' || - (feature.status && feature.status.startsWith('pipeline_')); - - if (isActiveState) { - const hasApprovedPlan = feature.planSpec?.status === 'approved'; - feature.status = hasApprovedPlan ? 'ready' : 'backlog'; - needsUpdate = true; - logger.info( - `[reconcileAllFeatureStates] Reset feature ${feature.id} from ${originalStatus} to ${feature.status}` - ); - } - - // Reset generating planSpec status back to pending - if (feature.planSpec?.status === 'generating') { - feature.planSpec.status = 'pending'; - needsUpdate = true; - logger.info( - `[reconcileAllFeatureStates] Reset feature ${feature.id} planSpec from generating to pending` - ); - } - - // Reset any in_progress tasks back to pending - if (feature.planSpec?.tasks) { - for (const task of feature.planSpec.tasks) { - if (task.status === 'in_progress') { - task.status = 'pending'; - needsUpdate = true; - logger.info( - `[reconcileAllFeatureStates] Reset task ${task.id} for feature ${feature.id} from in_progress to pending` - ); - if (feature.planSpec?.currentTaskId === task.id) { - feature.planSpec.currentTaskId = undefined; - } - } - } - } - - if (needsUpdate) { - feature.updatedAt = new Date().toISOString(); - await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT }); - featuresReconciled++; - reconciledFeatureIds.push(feature.id); - - // Emit per-feature status change event so UI invalidates its cache - this.emitAutoModeEvent('feature_status_changed', { - featureId: feature.id, - projectPath, - status: feature.status, - previousStatus: originalStatus, - reason: 'server_restart_reconciliation', - }); - } - } - - // Emit a bulk reconciliation event for the UI - if (featuresReconciled > 0) { - this.emitAutoModeEvent('features_reconciled', { - projectPath, - reconciledCount: featuresReconciled, - reconciledFeatureIds, - message: `Reconciled ${featuresReconciled} feature(s) after server restart`, - }); - } - - logger.info( - `[reconcileAllFeatureStates] Scanned ${featuresScanned} features, reconciled ${featuresReconciled} for ${projectPath}` - ); - - return featuresReconciled; - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - logger.error( - `[reconcileAllFeatureStates] Error reconciling features for ${projectPath}:`, - error - ); - } - return 0; + // Emit per-feature status change events so UI invalidates its cache + for (const { id, previousStatus, newStatus } of reconciledFeatures) { + this.emitAutoModeEvent('feature_status_changed', { + featureId: id, + projectPath, + status: newStatus, + previousStatus, + reason: 'server_restart_reconciliation', + }); } + + // Emit a bulk reconciliation event for the UI + if (reconciledCount > 0) { + this.emitAutoModeEvent('features_reconciled', { + projectPath, + reconciledCount, + reconciledFeatureIds, + message: `Reconciled ${reconciledCount} feature(s) after server restart`, + }); + } + + logger.info( + `[reconcileAllFeatureStates] Scanned ${scanned} features, reconciled ${reconciledCount} for ${projectPath}` + ); + + return reconciledCount; } /** @@ -683,7 +634,7 @@ export class FeatureStateManager { * @param eventType - The event type (e.g., 'auto_mode_summary') * @param data - The event payload */ - private emitAutoModeEvent(eventType: string, data: Record): void { + private emitAutoModeEvent(eventType: AutoModeEventType, data: Record): void { // Wrap the event in auto-mode:event format expected by the client this.events.emit('auto-mode:event', { type: eventType, diff --git a/apps/server/src/services/typed-event-bus.ts b/apps/server/src/services/typed-event-bus.ts index 42600db5..09d1e9bc 100644 --- a/apps/server/src/services/typed-event-bus.ts +++ b/apps/server/src/services/typed-event-bus.ts @@ -40,11 +40,13 @@ export type AutoModeEventType = | 'plan_rejected' | 'plan_revision_requested' | 'plan_revision_warning' + | 'plan_spec_updated' | 'pipeline_step_started' | 'pipeline_step_complete' + | 'pipeline_test_failed' + | 'pipeline_merge_conflict' | 'feature_status_changed' - | 'features_reconciled' - | string; // Allow other strings for extensibility + | 'features_reconciled'; /** * TypedEventBus wraps an EventEmitter to provide type-safe event emission diff --git a/apps/ui/src/types/electron.d.ts b/apps/ui/src/types/electron.d.ts index e44f19dd..82ab237a 100644 --- a/apps/ui/src/types/electron.d.ts +++ b/apps/ui/src/types/electron.d.ts @@ -3,7 +3,7 @@ */ import type { ClaudeUsageResponse, CodexUsageResponse } from '@/store/app-store'; -import type { ParsedTask } from '@automaker/types'; +import type { ParsedTask, FeatureStatusWithPipeline } from '@automaker/types'; export interface ImageAttachment { id?: string; // Optional - may not be present in messages loaded from server @@ -364,8 +364,8 @@ export type AutoModeEvent = type: 'feature_status_changed'; featureId: string; projectPath?: string; - status: string; - previousStatus: string; + status: FeatureStatusWithPipeline; + previousStatus: FeatureStatusWithPipeline; reason?: string; } | { diff --git a/libs/types/src/pipeline.ts b/libs/types/src/pipeline.ts index 05a4b4aa..7190abbd 100644 --- a/libs/types/src/pipeline.ts +++ b/libs/types/src/pipeline.ts @@ -21,6 +21,7 @@ export type PipelineStatus = `pipeline_${string}`; export type FeatureStatusWithPipeline = | 'backlog' + | 'ready' | 'in_progress' | 'interrupted' | 'waiting_approval' diff --git a/start-automaker.sh b/start-automaker.sh index 6770db2c..497ad305 100755 --- a/start-automaker.sh +++ b/start-automaker.sh @@ -36,8 +36,24 @@ elif [[ "$OSTYPE" == "darwin"* ]]; then fi # Port configuration -DEFAULT_WEB_PORT=3007 -DEFAULT_SERVER_PORT=3008 +# Defaults can be overridden via AUTOMAKER_WEB_PORT and AUTOMAKER_SERVER_PORT env vars + +# Validate env-provided ports early (before colors are available) +if [ -n "$AUTOMAKER_WEB_PORT" ]; then + if ! [[ "$AUTOMAKER_WEB_PORT" =~ ^[0-9]+$ ]] || [ "$AUTOMAKER_WEB_PORT" -lt 1 ] || [ "$AUTOMAKER_WEB_PORT" -gt 65535 ]; then + echo "Error: AUTOMAKER_WEB_PORT must be a number between 1-65535, got '$AUTOMAKER_WEB_PORT'" + exit 1 + fi +fi +if [ -n "$AUTOMAKER_SERVER_PORT" ]; then + if ! [[ "$AUTOMAKER_SERVER_PORT" =~ ^[0-9]+$ ]] || [ "$AUTOMAKER_SERVER_PORT" -lt 1 ] || [ "$AUTOMAKER_SERVER_PORT" -gt 65535 ]; then + echo "Error: AUTOMAKER_SERVER_PORT must be a number between 1-65535, got '$AUTOMAKER_SERVER_PORT'" + exit 1 + fi +fi + +DEFAULT_WEB_PORT=${AUTOMAKER_WEB_PORT:-3007} +DEFAULT_SERVER_PORT=${AUTOMAKER_SERVER_PORT:-3008} PORT_SEARCH_MAX_ATTEMPTS=100 WEB_PORT=$DEFAULT_WEB_PORT SERVER_PORT=$DEFAULT_SERVER_PORT @@ -136,6 +152,9 @@ EXAMPLES: start-automaker.sh docker # Launch Docker dev container start-automaker.sh --version # Show version + AUTOMAKER_WEB_PORT=4000 AUTOMAKER_SERVER_PORT=4001 start-automaker.sh web + # Launch web mode on custom ports + KEYBOARD SHORTCUTS (in menu): Up/Down arrows Navigate between options Enter Select highlighted option @@ -146,6 +165,10 @@ HISTORY: Your last selected mode is remembered in: ~/.automaker_launcher_history Use --no-history to disable this feature +ENVIRONMENT VARIABLES: + AUTOMAKER_WEB_PORT Override default web/UI port (default: 3007) + AUTOMAKER_SERVER_PORT Override default API server port (default: 3008) + PLATFORMS: Linux, macOS, Windows (Git Bash, WSL, MSYS2, Cygwin) From efcdd849b9dc6e2d45adeef6009a2db5b3eec5b6 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 17 Feb 2026 10:37:45 -0800 Subject: [PATCH 102/156] fix: Add 'ready' status to FeatureStatusWithPipeline type union --- .../views/board-view/components/kanban-card/card-header.tsx | 1 - libs/types/src/pipeline.ts | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx index 0f44ac21..ac80d7ed 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx @@ -206,7 +206,6 @@ export const CardHeaderSection = memo(function CardHeaderSection({ !isSelectionMode && (feature.status === 'backlog' || feature.status === 'interrupted' || - // @ts-expect-error 'ready' is a valid runtime status used for backlog display but not in FeatureStatusWithPipeline union feature.status === 'ready') && (
- )} - {feature.skipTests && onManualVerify ? ( - - ) : onResume ? ( - - ) : onVerify ? ( - - ) : null} - {onViewOutput && !feature.skipTests && ( - + {/* When feature is in_progress with no error and onForceStop is available, + it means the agent is starting/running but hasn't been added to runningAutoTasks yet. + Show Stop button instead of Verify/Resume to avoid confusing UI during this race window. */} + {!feature.error && onForceStop ? ( + <> + {onViewOutput && ( + + )} + + + ) : ( + <> + {/* Approve Plan button - shows when plan is generated and waiting for approval */} + {feature.planSpec?.status === 'generated' && onApprovePlan && ( + + )} + {feature.skipTests && onManualVerify ? ( + + ) : onResume ? ( + + ) : onVerify ? ( + + ) : null} + {onViewOutput && !feature.skipTests && ( + + )} + )} )} diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx index ab109d5f..b54c5fcc 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx @@ -112,9 +112,15 @@ export const KanbanCard = memo(function KanbanCard({ currentProject: state.currentProject, })) ); - // A card in waiting_approval should not display as "actively running" even if - // it's still in the runningAutoTasks list. The waiting_approval UI takes precedence. - const isActivelyRunning = !!isCurrentAutoTask && feature.status !== 'waiting_approval'; + // A card should only display as "actively running" if it's both in the + // runningAutoTasks list AND in an execution-compatible status. Cards in resting + // states (backlog, ready, waiting_approval, verified, completed) should never + // show running controls, even if they appear in runningAutoTasks due to stale + // state (e.g., after a server restart that reconciled features back to backlog). + const isInExecutionState = + feature.status === 'in_progress' || + (typeof feature.status === 'string' && feature.status.startsWith('pipeline_')); + const isActivelyRunning = !!isCurrentAutoTask && isInExecutionState; const [isLifted, setIsLifted] = useState(false); useLayoutEffect(() => { diff --git a/apps/ui/src/components/views/board-view/components/list-view/list-row.tsx b/apps/ui/src/components/views/board-view/components/list-view/list-row.tsx index 6d14c269..8fcd0bb9 100644 --- a/apps/ui/src/components/views/board-view/components/list-view/list-row.tsx +++ b/apps/ui/src/components/views/board-view/components/list-view/list-row.tsx @@ -209,9 +209,15 @@ export const ListRow = memo(function ListRow({ blockingDependencies = [], className, }: ListRowProps) { - // A card in waiting_approval should not display as "actively running" even if - // it's still in the runningAutoTasks list. The waiting_approval UI takes precedence. - const isActivelyRunning = isCurrentAutoTask && feature.status !== 'waiting_approval'; + // A row should only display as "actively running" if it's both in the + // runningAutoTasks list AND in an execution-compatible status. Features in resting + // states (backlog, ready, waiting_approval, verified, completed) should never + // show running controls, even if they appear in runningAutoTasks due to stale + // state (e.g., after a server restart that reconciled features back to backlog). + const isInExecutionState = + feature.status === 'in_progress' || + (typeof feature.status === 'string' && feature.status.startsWith('pipeline_')); + const isActivelyRunning = isCurrentAutoTask && isInExecutionState; const handleRowClick = useCallback( (e: React.MouseEvent) => { diff --git a/apps/ui/src/components/views/board-view/components/list-view/row-actions.tsx b/apps/ui/src/components/views/board-view/components/list-view/row-actions.tsx index 60158d0f..89462563 100644 --- a/apps/ui/src/components/views/board-view/components/list-view/row-actions.tsx +++ b/apps/ui/src/components/views/board-view/components/list-view/row-actions.tsx @@ -143,6 +143,17 @@ function getPrimaryAction( }; } + // In progress with no error - agent is starting/running but not yet in runningAutoTasks. + // Show Stop button immediately instead of Verify/Resume during this race window. + if (feature.status === 'in_progress' && !feature.error && handlers.onForceStop) { + return { + icon: StopCircle, + label: 'Stop', + onClick: handlers.onForceStop, + variant: 'destructive', + }; + } + // In progress with plan approval pending if ( feature.status === 'in_progress' && @@ -446,81 +457,126 @@ export const RowActions = memo(function RowActions({ )} - {/* In Progress actions */} - {!isCurrentAutoTask && feature.status === 'in_progress' && ( - <> - {handlers.onViewOutput && ( - - )} - {feature.planSpec?.status === 'generated' && handlers.onApprovePlan && ( - - )} - {feature.skipTests && handlers.onManualVerify ? ( - - ) : handlers.onResume ? ( - - ) : null} - - - {handlers.onSpawnTask && ( - - )} - {handlers.onDuplicate && ( - -
- - - Duplicate - + {/* In Progress actions - starting/running (no error, force stop available) - mirrors running task actions */} + {!isCurrentAutoTask && + feature.status === 'in_progress' && + !feature.error && + handlers.onForceStop && ( + <> + {handlers.onViewOutput && ( + + )} + {feature.planSpec?.status === 'generated' && handlers.onApprovePlan && ( + + )} + + {handlers.onSpawnTask && ( + + )} + {handlers.onForceStop && ( + <> + + + + )} + + )} + + {/* In Progress actions - interrupted/error state */} + {!isCurrentAutoTask && + feature.status === 'in_progress' && + !(!feature.error && handlers.onForceStop) && ( + <> + {handlers.onViewOutput && ( + + )} + {feature.planSpec?.status === 'generated' && handlers.onApprovePlan && ( + + )} + {feature.skipTests && handlers.onManualVerify ? ( + + ) : handlers.onResume ? ( + + ) : null} + + + {handlers.onSpawnTask && ( + + )} + {handlers.onDuplicate && ( + +
+ + + Duplicate + + {handlers.onDuplicateAsChild && ( + + )} +
{handlers.onDuplicateAsChild && ( - + + + )} -
- {handlers.onDuplicateAsChild && ( - - - - )} -
- )} - - - )} + + )} + + + )} {/* Waiting Approval actions */} {!isCurrentAutoTask && feature.status === 'waiting_approval' && ( diff --git a/apps/ui/src/components/views/board-view/mobile-usage-bar.tsx b/apps/ui/src/components/views/board-view/mobile-usage-bar.tsx index f6a89cf1..d78ce224 100644 --- a/apps/ui/src/components/views/board-view/mobile-usage-bar.tsx +++ b/apps/ui/src/components/views/board-view/mobile-usage-bar.tsx @@ -5,7 +5,6 @@ import { Spinner } from '@/components/ui/spinner'; import { getElectronAPI } from '@/lib/electron'; import { useAppStore } from '@/store/app-store'; import { AnthropicIcon, OpenAIIcon, ZaiIcon, GeminiIcon } from '@/components/ui/provider-icon'; -import type { GeminiUsage } from '@/store/app-store'; import { getExpectedWeeklyPacePercentage, getPaceStatusLabel } from '@/store/utils/usage-utils'; interface MobileUsageBarProps { @@ -42,6 +41,11 @@ function formatResetTime(unixTimestamp: number, isMilliseconds = false): string const now = new Date(); const diff = date.getTime() - now.getTime(); + // Handle past timestamps (negative diff) + if (diff <= 0) { + return 'Resetting soon'; + } + if (diff < 3600000) { const mins = Math.ceil(diff / 60000); return `Resets in ${mins}m`; @@ -184,12 +188,11 @@ export function MobileUsageBar({ const { claudeUsage, claudeUsageLastUpdated, setClaudeUsage } = useAppStore(); const { codexUsage, codexUsageLastUpdated, setCodexUsage } = useAppStore(); const { zaiUsage, zaiUsageLastUpdated, setZaiUsage } = useAppStore(); + const { geminiUsage, geminiUsageLastUpdated, setGeminiUsage } = useAppStore(); const [isClaudeLoading, setIsClaudeLoading] = useState(false); const [isCodexLoading, setIsCodexLoading] = useState(false); const [isZaiLoading, setIsZaiLoading] = useState(false); const [isGeminiLoading, setIsGeminiLoading] = useState(false); - const [geminiUsage, setGeminiUsage] = useState(null); - const [geminiUsageLastUpdated, setGeminiUsageLastUpdated] = useState(null); // Check if data is stale (older than 2 minutes) const isClaudeStale = @@ -254,15 +257,14 @@ export function MobileUsageBar({ if (!api.gemini) return; const data = await api.gemini.getUsage(); if (!('error' in data)) { - setGeminiUsage(data); - setGeminiUsageLastUpdated(Date.now()); + setGeminiUsage(data, Date.now()); } } catch { // Silently fail - usage display is optional } finally { setIsGeminiLoading(false); } - }, []); + }, [setGeminiUsage]); const getCodexWindowLabel = (durationMins: number) => { if (durationMins < 60) return `${durationMins}m Window`; diff --git a/apps/ui/src/components/views/settings-view/api-keys/hooks/use-api-key-management.ts b/apps/ui/src/components/views/settings-view/api-keys/hooks/use-api-key-management.ts index 1b6738ec..23ccd192 100644 --- a/apps/ui/src/components/views/settings-view/api-keys/hooks/use-api-key-management.ts +++ b/apps/ui/src/components/views/settings-view/api-keys/hooks/use-api-key-management.ts @@ -1,4 +1,4 @@ -// @ts-nocheck - API key management state with validation and persistence +// API key management state with validation and persistence import { useState, useEffect } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import { createLogger } from '@automaker/utils/logger'; @@ -23,20 +23,44 @@ interface ApiKeyStatus { hasZaiKey: boolean; } +/** Shape of the configure API response */ +interface ConfigureResponse { + success?: boolean; + isAvailable?: boolean; + error?: string; +} + +/** Shape of a verify API response */ +interface VerifyResponse { + success?: boolean; + authenticated?: boolean; + message?: string; + error?: string; +} + +/** Shape of an API key status response from the env check */ +interface ApiKeyStatusResponse { + success: boolean; + hasAnthropicKey: boolean; + hasGoogleKey: boolean; + hasOpenaiKey: boolean; + hasZaiKey?: boolean; +} + /** * Custom hook for managing API key state and operations * Handles input values, visibility toggles, connection testing, and saving */ export function useApiKeyManagement() { const { apiKeys, setApiKeys } = useAppStore(); - const { setZaiAuthStatus } = useSetupStore(); + const { setZaiAuthStatus, zaiAuthStatus } = useSetupStore(); const queryClient = useQueryClient(); // API key values - const [anthropicKey, setAnthropicKey] = useState(apiKeys.anthropic); - const [googleKey, setGoogleKey] = useState(apiKeys.google); - const [openaiKey, setOpenaiKey] = useState(apiKeys.openai); - const [zaiKey, setZaiKey] = useState(apiKeys.zai); + const [anthropicKey, setAnthropicKey] = useState(apiKeys.anthropic); + const [googleKey, setGoogleKey] = useState(apiKeys.google); + const [openaiKey, setOpenaiKey] = useState(apiKeys.openai); + const [zaiKey, setZaiKey] = useState(apiKeys.zai); // Visibility toggles const [showAnthropicKey, setShowAnthropicKey] = useState(false); @@ -74,7 +98,7 @@ export function useApiKeyManagement() { const api = getElectronAPI(); if (api?.setup?.getApiKeys) { try { - const status = await api.setup.getApiKeys(); + const status: ApiKeyStatusResponse = await api.setup.getApiKeys(); if (status.success) { setApiKeyStatus({ hasAnthropicKey: status.hasAnthropicKey, @@ -92,7 +116,7 @@ export function useApiKeyManagement() { }, []); // Test Anthropic/Claude connection - const handleTestAnthropicConnection = async () => { + const handleTestAnthropicConnection = async (): Promise => { // Validate input first if (!anthropicKey || anthropicKey.trim().length === 0) { setTestResult({ @@ -106,7 +130,7 @@ export function useApiKeyManagement() { setTestResult(null); try { - const api = getElectronAPI(); + const api = getHttpApiClient(); // Pass the current input value to test unsaved keys const data = await api.setup.verifyClaudeAuth('api_key', anthropicKey); @@ -133,7 +157,7 @@ export function useApiKeyManagement() { // Test Google/Gemini connection // TODO: Add backend endpoint for Gemini API key verification - const handleTestGeminiConnection = async () => { + const handleTestGeminiConnection = async (): Promise => { setTestingGeminiConnection(true); setGeminiTestResult(null); @@ -157,12 +181,12 @@ export function useApiKeyManagement() { }; // Test OpenAI/Codex connection - const handleTestOpenaiConnection = async () => { + const handleTestOpenaiConnection = async (): Promise => { setTestingOpenaiConnection(true); setOpenaiTestResult(null); try { - const api = getElectronAPI(); + const api = getHttpApiClient(); const data = await api.setup.verifyCodexAuth('api_key', openaiKey); if (data.success && data.authenticated) { @@ -187,7 +211,7 @@ export function useApiKeyManagement() { }; // Test z.ai connection - const handleTestZaiConnection = async () => { + const handleTestZaiConnection = async (): Promise => { setTestingZaiConnection(true); setZaiTestResult(null); @@ -204,7 +228,7 @@ export function useApiKeyManagement() { try { const api = getElectronAPI(); // Use the verify endpoint to test the key without storing it - const response = await api.zai?.verify(zaiKey); + const response: VerifyResponse | undefined = await api.zai?.verify(zaiKey); if (response?.success && response?.authenticated) { setZaiTestResult({ @@ -228,42 +252,70 @@ export function useApiKeyManagement() { }; // Save API keys - const handleSave = async () => { - setApiKeys({ - anthropic: anthropicKey, - google: googleKey, - openai: openaiKey, - zai: zaiKey, - }); - + const handleSave = async (): Promise => { // Configure z.ai service on the server with the new key if (zaiKey && zaiKey.trim().length > 0) { try { const api = getHttpApiClient(); - const result = await api.zai.configure(zaiKey.trim()); + const result: ConfigureResponse = await api.zai.configure(zaiKey.trim()); + + if (result.success) { + // Only persist to local store after server confirms success + setApiKeys({ + anthropic: anthropicKey, + google: googleKey, + openai: openaiKey, + zai: zaiKey, + }); + + // Preserve the existing hasEnvApiKey flag from current auth status + const currentHasEnvApiKey = zaiAuthStatus?.hasEnvApiKey ?? false; - if (result.success || result.isAvailable) { // Update z.ai auth status in the store setZaiAuthStatus({ authenticated: true, method: 'api_key' as ZaiAuthMethod, hasApiKey: true, - hasEnvApiKey: false, + hasEnvApiKey: currentHasEnvApiKey, }); // Invalidate the z.ai usage query so it refetches with the new key await queryClient.invalidateQueries({ queryKey: queryKeys.usage.zai() }); logger.info('z.ai API key configured successfully'); + } else { + // Server config failed - still save other keys but log the issue + logger.error('z.ai API key configuration failed on server'); + setApiKeys({ + anthropic: anthropicKey, + google: googleKey, + openai: openaiKey, + zai: zaiKey, + }); } } catch (error) { logger.error('Failed to configure z.ai API key:', error); + // Still save other keys even if z.ai config fails + setApiKeys({ + anthropic: anthropicKey, + google: googleKey, + openai: openaiKey, + zai: zaiKey, + }); } } else { + // Save keys (z.ai key is empty/removed) + setApiKeys({ + anthropic: anthropicKey, + google: googleKey, + openai: openaiKey, + zai: zaiKey, + }); + // Clear z.ai auth status if key is removed setZaiAuthStatus({ authenticated: false, method: 'none' as ZaiAuthMethod, hasApiKey: false, - hasEnvApiKey: false, + hasEnvApiKey: zaiAuthStatus?.hasEnvApiKey ?? false, }); // Invalidate the query to clear any cached data await queryClient.invalidateQueries({ queryKey: queryKeys.usage.zai() }); diff --git a/apps/ui/src/hooks/use-auto-mode.ts b/apps/ui/src/hooks/use-auto-mode.ts index c6dba5b3..70da21ee 100644 --- a/apps/ui/src/hooks/use-auto-mode.ts +++ b/apps/ui/src/hooks/use-auto-mode.ts @@ -172,7 +172,10 @@ export function useAutoMode(worktree?: WorktreeInfo) { (backendIsRunning && Array.isArray(backendRunningFeatures) && backendRunningFeatures.length > 0 && - !arraysEqual(backendRunningFeatures, runningAutoTasks)); + !arraysEqual(backendRunningFeatures, runningAutoTasks)) || + // Also sync when UI has stale running tasks but backend has none + // (handles server restart where features were reconciled to backlog/ready) + (!backendIsRunning && runningAutoTasks.length > 0 && backendRunningFeatures.length === 0); if (needsSync) { const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree'; diff --git a/apps/ui/src/hooks/use-provider-auth-init.ts b/apps/ui/src/hooks/use-provider-auth-init.ts index f8919b1e..02ae801e 100644 --- a/apps/ui/src/hooks/use-provider-auth-init.ts +++ b/apps/ui/src/hooks/use-provider-auth-init.ts @@ -108,22 +108,41 @@ export function useProviderAuthInit() { try { const result = await api.zai.getStatus(); if (result.success || result.available !== undefined) { + const available = !!result.available; + const hasApiKey = !!(result.hasApiKey ?? result.available); + const hasEnvApiKey = !!(result.hasEnvApiKey ?? false); + let method: ZaiAuthMethod = 'none'; - if (result.hasEnvApiKey) { + if (hasEnvApiKey) { method = 'api_key_env'; - } else if (result.hasApiKey || result.available) { + } else if (hasApiKey || available) { method = 'api_key'; } setZaiAuthStatus({ - authenticated: result.available, + authenticated: available, method, - hasApiKey: result.hasApiKey ?? result.available, - hasEnvApiKey: result.hasEnvApiKey ?? false, + hasApiKey, + hasEnvApiKey, + }); + } else { + // Non-success path - set default unauthenticated status + setZaiAuthStatus({ + authenticated: false, + method: 'none', + hasApiKey: false, + hasEnvApiKey: false, }); } } catch (error) { logger.error('Failed to init z.ai auth status:', error); + // Set default status on error to prevent stale state + setZaiAuthStatus({ + authenticated: false, + method: 'none', + hasApiKey: false, + hasEnvApiKey: false, + }); } // 4. Gemini Auth Status @@ -134,7 +153,7 @@ export function useProviderAuthInit() { setGeminiCliStatus({ installed: result.installed ?? false, version: result.version, - path: result.status, + path: result.path, }); // Set Auth status - always set a status to mark initialization as complete diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 4238f58e..71fb68f7 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -41,7 +41,12 @@ import type { Notification, } from '@automaker/types'; import type { Message, SessionListItem } from '@/types/electron'; -import type { ClaudeUsageResponse, CodexUsageResponse, GeminiUsage } from '@/store/app-store'; +import type { + ClaudeUsageResponse, + CodexUsageResponse, + GeminiUsage, + ZaiUsageResponse, +} from '@/store/app-store'; import type { WorktreeAPI, GitAPI, ModelDefinition, ProviderStatus } from '@/types/electron'; import type { ModelId, ThinkingLevel, ReasoningEffort, Feature } from '@automaker/types'; import { getGlobalFileBrowser } from '@/contexts/file-browser-context'; @@ -1748,35 +1753,7 @@ export class HttpApiClient implements ElectronAPI { error?: string; }> => this.get('/api/zai/status'), - getUsage: (): Promise<{ - quotaLimits?: { - tokens?: { - limitType: string; - limit: number; - used: number; - remaining: number; - usedPercent: number; - nextResetTime: number; - }; - time?: { - limitType: string; - limit: number; - used: number; - remaining: number; - usedPercent: number; - nextResetTime: number; - }; - planType: string; - } | null; - usageDetails?: Array<{ - modelId: string; - used: number; - limit: number; - }>; - lastUpdated: string; - error?: string; - message?: string; - }> => this.get('/api/zai/usage'), + getUsage: (): Promise => this.get('/api/zai/usage'), configure: ( apiToken?: string, diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 034b1aa5..235fd9f6 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -321,6 +321,8 @@ const initialState: AppState = { codexUsageLastUpdated: null, zaiUsage: null, zaiUsageLastUpdated: null, + geminiUsage: null, + geminiUsageLastUpdated: null, codexModels: [], codexModelsLoading: false, codexModelsError: null, @@ -2410,6 +2412,13 @@ export const useAppStore = create()((set, get) => ({ // z.ai Usage Tracking actions setZaiUsage: (usage) => set({ zaiUsage: usage, zaiUsageLastUpdated: usage ? Date.now() : null }), + // Gemini Usage Tracking actions + setGeminiUsage: (usage, lastUpdated) => + set({ + geminiUsage: usage, + geminiUsageLastUpdated: lastUpdated ?? (usage ? Date.now() : null), + }), + // Codex Models actions fetchCodexModels: async (forceRefresh = false) => { const state = get(); diff --git a/apps/ui/src/store/types/state-types.ts b/apps/ui/src/store/types/state-types.ts index bff4d474..25bc3dfa 100644 --- a/apps/ui/src/store/types/state-types.ts +++ b/apps/ui/src/store/types/state-types.ts @@ -34,7 +34,7 @@ import type { ApiKeys } from './settings-types'; import type { ChatMessage, ChatSession } from './chat-types'; import type { TerminalState, TerminalPanelContent, PersistedTerminalState } from './terminal-types'; import type { Feature, ProjectAnalysis } from './project-types'; -import type { ClaudeUsage, CodexUsage, ZaiUsage } from './usage-types'; +import type { ClaudeUsage, CodexUsage, ZaiUsage, GeminiUsage } from './usage-types'; /** State for worktree init script execution */ export interface InitScriptState { @@ -299,6 +299,10 @@ export interface AppState { zaiUsage: ZaiUsage | null; zaiUsageLastUpdated: number | null; + // Gemini Usage Tracking + geminiUsage: GeminiUsage | null; + geminiUsageLastUpdated: number | null; + // Codex Models (dynamically fetched) codexModels: Array<{ id: string; @@ -769,6 +773,9 @@ export interface AppActions { // z.ai Usage Tracking actions setZaiUsage: (usage: ZaiUsage | null) => void; + // Gemini Usage Tracking actions + setGeminiUsage: (usage: GeminiUsage | null, lastUpdated?: number) => void; + // Codex Models actions fetchCodexModels: (forceRefresh?: boolean) => Promise; setCodexModels: ( From 7fcf3c1e1fbf7f4e139f516375c1c1211ea03260 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 17 Feb 2026 15:20:28 -0800 Subject: [PATCH 104/156] feat: Mobile improvements and Add selective file staging and improve branch switching --- .../src/routes/worktree/routes/commit.ts | 18 +- .../routes/worktree/routes/list-branches.ts | 3 + .../routes/worktree/routes/switch-branch.ts | 302 +++++++++-- .../src/services/auto-loop-coordinator.ts | 6 +- apps/ui/index.html | 76 ++- apps/ui/public/manifest.json | 44 ++ apps/ui/public/sw.js | 373 +++++++++++++ apps/ui/src/app.tsx | 32 ++ .../dialogs/sandbox-rejection-screen.tsx | 2 +- apps/ui/src/components/ui/dialog.tsx | 2 +- apps/ui/src/components/views/board-view.tsx | 58 +- .../dialogs/commit-worktree-dialog.tsx | 503 +++++++++++++++++- .../dialogs/view-worktree-changes-dialog.tsx | 2 +- .../board-view/hooks/use-board-actions.ts | 8 +- .../board-view/init-script-indicator.tsx | 2 +- .../components/branch-switch-dropdown.tsx | 83 ++- .../worktree-panel/hooks/use-branches.ts | 2 +- .../hooks/use-worktree-actions.ts | 15 +- .../views/board-view/worktree-panel/types.ts | 8 + .../worktree-panel/worktree-panel.tsx | 12 +- .../src/components/views/dashboard-view.tsx | 2 +- .../src/components/views/logged-out-view.tsx | 2 +- apps/ui/src/components/views/login-view.tsx | 8 +- .../ui/src/components/views/overview-view.tsx | 2 +- .../mobile-terminal-controls.tsx | 310 +++++++++++ .../views/terminal-view/terminal-panel.tsx | 27 + .../hooks/mutations/use-worktree-mutations.ts | 55 +- apps/ui/src/hooks/use-auto-mode.ts | 7 +- apps/ui/src/hooks/use-event-recency.ts | 19 +- apps/ui/src/hooks/use-mobile-visibility.ts | 127 +++++ .../src/hooks/use-virtual-keyboard-resize.ts | 64 +++ apps/ui/src/lib/electron.ts | 4 +- apps/ui/src/lib/http-api-client.ts | 4 +- apps/ui/src/lib/mobile-detect.ts | 75 +++ apps/ui/src/lib/query-client.ts | 76 ++- apps/ui/src/renderer.tsx | 129 +++++ apps/ui/src/routes/__root.tsx | 16 +- apps/ui/src/store/app-store.ts | 5 +- apps/ui/src/styles/font-imports.ts | 323 +++++++---- apps/ui/src/styles/global.css | 35 ++ apps/ui/src/types/electron.d.ts | 3 +- apps/ui/vite.config.mts | 118 +++- 42 files changed, 2706 insertions(+), 256 deletions(-) create mode 100644 apps/ui/public/manifest.json create mode 100644 apps/ui/public/sw.js create mode 100644 apps/ui/src/components/views/terminal-view/mobile-terminal-controls.tsx create mode 100644 apps/ui/src/hooks/use-mobile-visibility.ts create mode 100644 apps/ui/src/hooks/use-virtual-keyboard-resize.ts create mode 100644 apps/ui/src/lib/mobile-detect.ts diff --git a/apps/server/src/routes/worktree/routes/commit.ts b/apps/server/src/routes/worktree/routes/commit.ts index f33cd94b..7571fd91 100644 --- a/apps/server/src/routes/worktree/routes/commit.ts +++ b/apps/server/src/routes/worktree/routes/commit.ts @@ -15,9 +15,10 @@ const execAsync = promisify(exec); export function createCommitHandler() { return async (req: Request, res: Response): Promise => { try { - const { worktreePath, message } = req.body as { + const { worktreePath, message, files } = req.body as { worktreePath: string; message: string; + files?: string[]; }; if (!worktreePath || !message) { @@ -44,8 +45,19 @@ export function createCommitHandler() { return; } - // Stage all changes - await execAsync('git add -A', { cwd: worktreePath }); + // Stage changes - either specific files or all changes + if (files && files.length > 0) { + // Reset any previously staged changes first + await execAsync('git reset HEAD', { cwd: worktreePath }).catch(() => { + // Ignore errors from reset (e.g., if nothing is staged) + }); + // Stage only the selected files + const escapedFiles = files.map((f) => `"${f.replace(/"/g, '\\"')}"`).join(' '); + await execAsync(`git add ${escapedFiles}`, { cwd: worktreePath }); + } else { + // Stage all changes (original behavior) + await execAsync('git add -A', { cwd: worktreePath }); + } // Create commit await execAsync(`git commit -m "${message.replace(/"/g, '\\"')}"`, { diff --git a/apps/server/src/routes/worktree/routes/list-branches.ts b/apps/server/src/routes/worktree/routes/list-branches.ts index 2e6a34f5..30fdcb1d 100644 --- a/apps/server/src/routes/worktree/routes/list-branches.ts +++ b/apps/server/src/routes/worktree/routes/list-branches.ts @@ -92,6 +92,9 @@ export function createListBranchesHandler() { // Skip HEAD pointers like "origin/HEAD" if (cleanName.includes('/HEAD')) return; + // Skip bare remote names without a branch (e.g. "origin" by itself) + if (!cleanName.includes('/')) return; + // Only add remote branches if a branch with the exact same name isn't already // in the list. This avoids duplicates if a local branch is named like a remote one. // Note: We intentionally include remote branches even when a local branch with the diff --git a/apps/server/src/routes/worktree/routes/switch-branch.ts b/apps/server/src/routes/worktree/routes/switch-branch.ts index 63be752b..beb380ad 100644 --- a/apps/server/src/routes/worktree/routes/switch-branch.ts +++ b/apps/server/src/routes/worktree/routes/switch-branch.ts @@ -1,9 +1,15 @@ /** * POST /switch-branch endpoint - Switch to an existing branch * - * Simple branch switching. - * If there are uncommitted changes, the switch will fail and - * the user should commit first. + * Handles branch switching with automatic stash/reapply of local changes. + * If there are uncommitted changes, they are stashed before switching and + * reapplied after. If the stash pop results in merge conflicts, returns + * a special response code so the UI can create a conflict resolution task. + * + * For remote branches (e.g., "origin/feature"), automatically creates a + * local tracking branch and checks it out. + * + * Also fetches the latest remote refs after switching. * * Note: Git repository validation (isGitRepo, hasCommits) is handled by * the requireValidWorktree middleware in index.ts @@ -16,14 +22,14 @@ import { getErrorMessage, logError } from '../common.js'; const execAsync = promisify(exec); -function isUntrackedLine(line: string): boolean { - return line.startsWith('?? '); -} - function isExcludedWorktreeLine(line: string): boolean { return line.includes('.worktrees/') || line.endsWith('.worktrees'); } +function isUntrackedLine(line: string): boolean { + return line.startsWith('?? '); +} + function isBlockingChangeLine(line: string): boolean { if (!line.trim()) return false; if (isExcludedWorktreeLine(line)) return false; @@ -46,18 +52,130 @@ async function hasUncommittedChanges(cwd: string): Promise { } /** - * Get a summary of uncommitted changes for user feedback - * Excludes .worktrees/ directory + * Check if there are any changes at all (including untracked) that should be stashed */ -async function getChangesSummary(cwd: string): Promise { +async function hasAnyChanges(cwd: string): Promise { try { - const { stdout } = await execAsync('git status --short', { cwd }); - const lines = stdout.trim().split('\n').filter(isBlockingChangeLine); - if (lines.length === 0) return ''; - if (lines.length <= 5) return lines.join(', '); - return `${lines.slice(0, 5).join(', ')} and ${lines.length - 5} more files`; + const { stdout } = await execAsync('git status --porcelain', { cwd }); + const lines = stdout + .trim() + .split('\n') + .filter((line) => { + if (!line.trim()) return false; + if (isExcludedWorktreeLine(line)) return false; + return true; + }); + return lines.length > 0; } catch { - return 'unknown changes'; + return false; + } +} + +/** + * Stash all local changes (including untracked files) + * Returns true if a stash was created, false if there was nothing to stash + */ +async function stashChanges(cwd: string, message: string): Promise { + try { + // Get stash count before + const { stdout: beforeCount } = await execAsync('git stash list', { cwd }); + const countBefore = beforeCount + .trim() + .split('\n') + .filter((l) => l.trim()).length; + + // Stash including untracked files + await execAsync(`git stash push --include-untracked -m "${message}"`, { cwd }); + + // Get stash count after to verify something was stashed + const { stdout: afterCount } = await execAsync('git stash list', { cwd }); + const countAfter = afterCount + .trim() + .split('\n') + .filter((l) => l.trim()).length; + + return countAfter > countBefore; + } catch { + return false; + } +} + +/** + * Pop the most recent stash entry + * Returns an object indicating success and whether there were conflicts + */ +async function popStash( + cwd: string +): Promise<{ success: boolean; hasConflicts: boolean; error?: string }> { + try { + const { stdout, stderr } = await execAsync('git stash pop', { cwd }); + const output = `${stdout}\n${stderr}`; + // Check for conflict markers in the output + if (output.includes('CONFLICT') || output.includes('Merge conflict')) { + return { success: false, hasConflicts: true }; + } + return { success: true, hasConflicts: false }; + } catch (error) { + const errorMsg = getErrorMessage(error); + if (errorMsg.includes('CONFLICT') || errorMsg.includes('Merge conflict')) { + return { success: false, hasConflicts: true, error: errorMsg }; + } + return { success: false, hasConflicts: false, error: errorMsg }; + } +} + +/** + * Fetch latest from all remotes (silently, with timeout) + */ +async function fetchRemotes(cwd: string): Promise { + try { + await execAsync('git fetch --all --quiet', { + cwd, + timeout: 15000, // 15 second timeout + }); + } catch { + // Ignore fetch errors - we may be offline + } +} + +/** + * Parse a remote branch name like "origin/feature-branch" into its parts + */ +function parseRemoteBranch(branchName: string): { remote: string; branch: string } | null { + const slashIndex = branchName.indexOf('/'); + if (slashIndex === -1) return null; + return { + remote: branchName.substring(0, slashIndex), + branch: branchName.substring(slashIndex + 1), + }; +} + +/** + * Check if a branch name refers to a remote branch + */ +async function isRemoteBranch(cwd: string, branchName: string): Promise { + try { + const { stdout } = await execAsync('git branch -r --format="%(refname:short)"', { cwd }); + const remoteBranches = stdout + .trim() + .split('\n') + .map((b) => b.trim().replace(/^['"]|['"]$/g, '')) + .filter((b) => b); + return remoteBranches.includes(branchName); + } catch { + return false; + } +} + +/** + * Check if a local branch already exists + */ +async function localBranchExists(cwd: string, branchName: string): Promise { + try { + await execAsync(`git rev-parse --verify "refs/heads/${branchName}"`, { cwd }); + return true; + } catch { + return false; } } @@ -91,53 +209,133 @@ export function createSwitchBranchHandler() { }); const previousBranch = currentBranchOutput.trim(); - if (previousBranch === branchName) { + // Determine the actual target branch name for checkout + let targetBranch = branchName; + let isRemote = false; + + // Check if this is a remote branch (e.g., "origin/feature-branch") + if (await isRemoteBranch(worktreePath, branchName)) { + isRemote = true; + const parsed = parseRemoteBranch(branchName); + if (parsed) { + // If a local branch with the same name already exists, just switch to it + if (await localBranchExists(worktreePath, parsed.branch)) { + targetBranch = parsed.branch; + } else { + // Will create a local tracking branch from the remote + targetBranch = parsed.branch; + } + } + } + + if (previousBranch === targetBranch) { res.json({ success: true, result: { previousBranch, - currentBranch: branchName, - message: `Already on branch '${branchName}'`, + currentBranch: targetBranch, + message: `Already on branch '${targetBranch}'`, }, }); return; } - // Check if branch exists + // Check if target branch exists (locally or as remote ref) + if (!isRemote) { + try { + await execAsync(`git rev-parse --verify "${branchName}"`, { + cwd: worktreePath, + }); + } catch { + res.status(400).json({ + success: false, + error: `Branch '${branchName}' does not exist`, + }); + return; + } + } + + // Stash local changes if any exist + const hadChanges = await hasAnyChanges(worktreePath); + let didStash = false; + + if (hadChanges) { + const stashMessage = `automaker-branch-switch: ${previousBranch} β†’ ${targetBranch}`; + didStash = await stashChanges(worktreePath, stashMessage); + } + try { - await execAsync(`git rev-parse --verify ${branchName}`, { - cwd: worktreePath, - }); - } catch { - res.status(400).json({ - success: false, - error: `Branch '${branchName}' does not exist`, - }); - return; + // Switch to the target branch + if (isRemote) { + const parsed = parseRemoteBranch(branchName); + if (parsed) { + if (await localBranchExists(worktreePath, parsed.branch)) { + // Local branch exists, just checkout + await execAsync(`git checkout "${parsed.branch}"`, { cwd: worktreePath }); + } else { + // Create local tracking branch from remote + await execAsync(`git checkout -b "${parsed.branch}" "${branchName}"`, { + cwd: worktreePath, + }); + } + } + } else { + await execAsync(`git checkout "${targetBranch}"`, { cwd: worktreePath }); + } + + // Fetch latest from remotes after switching + await fetchRemotes(worktreePath); + + // Reapply stashed changes if we stashed earlier + let hasConflicts = false; + let conflictMessage = ''; + + if (didStash) { + const popResult = await popStash(worktreePath); + if (popResult.hasConflicts) { + hasConflicts = true; + conflictMessage = `Switched to branch '${targetBranch}' but merge conflicts occurred when reapplying your local changes. Please resolve the conflicts.`; + } else if (!popResult.success) { + // Stash pop failed for a non-conflict reason - the stash is still there + conflictMessage = `Switched to branch '${targetBranch}' but failed to reapply stashed changes: ${popResult.error}. Your changes are still in the stash.`; + } + } + + if (hasConflicts) { + res.json({ + success: true, + result: { + previousBranch, + currentBranch: targetBranch, + message: conflictMessage, + hasConflicts: true, + stashedChanges: true, + }, + }); + } else { + const stashNote = didStash ? ' (local changes stashed and reapplied)' : ''; + res.json({ + success: true, + result: { + previousBranch, + currentBranch: targetBranch, + message: `Switched to branch '${targetBranch}'${stashNote}`, + hasConflicts: false, + stashedChanges: didStash, + }, + }); + } + } catch (checkoutError) { + // If checkout failed and we stashed, try to restore the stash + if (didStash) { + try { + await popStash(worktreePath); + } catch { + // Ignore errors restoring stash - it's still in the stash list + } + } + throw checkoutError; } - - // Check for uncommitted changes - if (await hasUncommittedChanges(worktreePath)) { - const summary = await getChangesSummary(worktreePath); - res.status(400).json({ - success: false, - error: `Cannot switch branches: you have uncommitted changes (${summary}). Please commit your changes first.`, - code: 'UNCOMMITTED_CHANGES', - }); - return; - } - - // Switch to the target branch - await execAsync(`git checkout "${branchName}"`, { cwd: worktreePath }); - - res.json({ - success: true, - result: { - previousBranch, - currentBranch: branchName, - message: `Switched to branch '${branchName}'`, - }, - }); } catch (error) { logError(error, 'Switch branch failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); diff --git a/apps/server/src/services/auto-loop-coordinator.ts b/apps/server/src/services/auto-loop-coordinator.ts index 3310b2d6..64a3bd2f 100644 --- a/apps/server/src/services/auto-loop-coordinator.ts +++ b/apps/server/src/services/auto-loop-coordinator.ts @@ -387,8 +387,10 @@ export class AutoLoopCoordinator { const projectId = settings.projects?.find((p) => p.path === projectPath)?.id; const autoModeByWorktree = settings.autoModeByWorktree; if (projectId && autoModeByWorktree && typeof autoModeByWorktree === 'object') { - const normalizedBranch = - branchName === null || branchName === 'main' ? '__main__' : branchName; + // branchName is already normalized to null for the primary branch by callers + // (e.g., checkWorktreeCapacity, startAutoLoopForProject), so we only + // need to convert null to '__main__' for the worktree key lookup + const normalizedBranch = branchName === null ? '__main__' : branchName; const worktreeId = `${projectId}::${normalizedBranch}`; if ( worktreeId in autoModeByWorktree && diff --git a/apps/ui/index.html b/apps/ui/index.html index 49a7aa1e..3f12c9b0 100644 --- a/apps/ui/index.html +++ b/apps/ui/index.html @@ -4,18 +4,84 @@ Automaker - Autonomous AI Development Studio - + + + + + + + + + + + + + + + -
+
+ +
+ +
+
+
diff --git a/apps/ui/public/sw.js b/apps/ui/public/sw.js index ce97fc2a..1370cb7c 100644 --- a/apps/ui/public/sw.js +++ b/apps/ui/public/sw.js @@ -1,8 +1,8 @@ // Automaker Service Worker - Optimized for mobile PWA loading performance // NOTE: CACHE_NAME is injected with a build hash at build time by the swCacheBuster // Vite plugin (see vite.config.mts). In development it stays as-is; in production -// builds it becomes e.g. 'automaker-v3-a1b2c3d4' for automatic cache invalidation. -const CACHE_NAME = 'automaker-v3'; // replaced at build time β†’ 'automaker-v3-' +// builds it becomes e.g. 'automaker-v5-a1b2c3d4' for automatic cache invalidation. +const CACHE_NAME = 'automaker-v5'; // replaced at build time β†’ 'automaker-v5-' // Separate cache for immutable hashed assets (long-lived) const IMMUTABLE_CACHE = 'automaker-immutable-v2'; @@ -13,6 +13,7 @@ const API_CACHE = 'automaker-api-v1'; // Assets to cache on install (app shell for instant loading) const SHELL_ASSETS = [ '/', + '/index.html', '/manifest.json', '/logo.png', '/logo_larger.png', @@ -20,6 +21,12 @@ const SHELL_ASSETS = [ '/favicon.ico', ]; +// Critical JS/CSS assets extracted from index.html at build time by the swCacheBuster +// Vite plugin. Populated during production builds; empty in dev mode. +// These are precached on SW install so that PWA cold starts after memory eviction +// serve instantly from cache instead of requiring a full network download. +const CRITICAL_ASSETS = []; + // Whether mobile caching is enabled (set via message from main thread). // Persisted to Cache Storage so it survives aggressive SW termination on mobile. let mobileMode = false; @@ -60,7 +67,10 @@ async function restoreMobileMode() { } // Restore mobileMode immediately on SW startup -restoreMobileMode(); +// Keep a promise so fetch handlers can await restoration on cold SW starts. +// This prevents a race where early API requests run before mobileMode is loaded +// from Cache Storage, incorrectly falling back to network-first. +const mobileModeRestorePromise = restoreMobileMode(); // API endpoints that are safe to serve from stale cache on mobile. // These are GET-only, read-heavy endpoints where showing slightly stale data @@ -121,13 +131,68 @@ async function addCacheTimestamp(response) { } self.addEventListener('install', (event) => { + // Cache the app shell AND critical JS/CSS assets so the PWA loads instantly. + // SHELL_ASSETS go into CACHE_NAME (general cache), CRITICAL_ASSETS go into + // IMMUTABLE_CACHE (long-lived, content-hashed assets). This ensures that even + // the very first visit populates the immutable cache β€” previously, assets were + // only cached on fetch interception, but the SW isn't active during the first + // page load so nothing got cached until the second visit. + // + // self.skipWaiting() is NOT called here β€” activation is deferred until the main + // thread sends a SKIP_WAITING message to avoid disrupting a live page. event.waitUntil( - caches.open(CACHE_NAME).then((cache) => { - return cache.addAll(SHELL_ASSETS); - }) + Promise.all([ + // Cache app shell (HTML, icons, manifest) using individual fetch+put instead of + // cache.addAll(). This is critical because cache.addAll() respects the server's + // Cache-Control response headers β€” if the server sends 'Cache-Control: no-store' + // (which Vite dev server does for index.html), addAll() silently skips caching + // and the pre-React loading spinner is never served from cache. + // + // cache.put() bypasses Cache-Control headers entirely, ensuring the app shell + // is always cached on install regardless of what the server sends. This is the + // correct approach for SW-managed caches where the SW (not HTTP headers) controls + // freshness via the activate event's cache cleanup and the navigation strategy's + // background revalidation. + caches.open(CACHE_NAME).then((cache) => + Promise.all( + SHELL_ASSETS.map((url) => + fetch(url) + .then((response) => { + if (response.ok) return cache.put(url, response); + }) + .catch(() => { + // Individual asset fetch failure is non-fatal β€” the SW still activates + // and the next navigation will populate the cache via Strategy 3. + }) + ) + ) + ), + // Cache critical JS/CSS bundles (injected at build time by swCacheBuster). + // Uses individual fetch+put instead of cache.addAll() so a single asset + // failure doesn't prevent the rest from being cached. + // + // IMPORTANT: We fetch with { mode: 'cors' } because Vite's output HTML uses + //