Compare commits

..

170 Commits

Author SHA1 Message Date
Stefan de Vogelaere
b88c940a36 feat: unify Claude API key and profile system with flexible key sourcing
- Add ApiKeySource type ('inline' | 'env' | 'credentials') to ClaudeApiProfile
- Allow profiles to source API keys from credentials.json or environment variables
- Add provider templates: OpenRouter, MiniMax, MiniMax (China)
- Auto-migrate existing users with Anthropic key to "Direct Anthropic" profile
- Update all API call sites to pass credentials for key resolution
- Add API key source selector to profile creation UI
- Increment settings version to 5 for migration support

This allows users to:
- Share a single API key across multiple profile configurations
- Use environment variables for CI/CD deployments
- Easily switch between providers without re-entering keys
2026-01-19 17:28:28 +01:00
Stefan de Vogelaere
10b49bd3b4 Merge remote-tracking branch 'origin/v0.13.0rc' into feature/claude-code-max-glm-api-keys 2026-01-19 14:42:15 +01:00
DhanushSantosh
63b8eb0991 chore: refresh package-lock 2026-01-19 17:22:55 +05:30
Stefan de Vogelaere
a52c0461e5 feat: add external terminal support with cross-platform detection (#565)
* feat(platform): add cross-platform openInTerminal utility

Add utility function to open a terminal in a specified directory:
- macOS: Uses Terminal.app via AppleScript
- Windows: Tries Windows Terminal, falls back to cmd
- Linux: Tries common terminal emulators (gnome-terminal,
  konsole, xfce4-terminal, xterm, x-terminal-emulator)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(server): add open-in-terminal endpoint

Add POST /open-in-terminal endpoint to open a system terminal in the
worktree directory using the cross-platform openInTerminal utility.

The endpoint validates that worktreePath is provided and is an
absolute path for security.

Extracted from PR #558.

* feat(ui): add Open in Terminal action to worktree dropdown

Add "Open in Terminal" option to the worktree actions dropdown menu.
This opens the system terminal in the worktree directory.

Changes:
- Add openInTerminal method to http-api-client
- Add Terminal icon and menu item to worktree-actions-dropdown
- Add onOpenInTerminal prop to WorktreeTab component
- Add handleOpenInTerminal handler to use-worktree-actions hook
- Wire up handler in worktree-panel for both mobile and desktop views

Extracted from PR #558.

* fix(ui): open in terminal navigates to Automaker terminal view

Instead of opening the system terminal, the "Open in Terminal" action
now opens Automaker's built-in terminal with the worktree directory:

- Add pendingTerminalCwd state to app store
- Update use-worktree-actions to set pending cwd and navigate to /terminal
- Add effect in terminal-view to create session with pending cwd

This matches the original PR #558 behavior.

* feat(ui): add terminal open mode setting (new tab vs split)

Add a setting to choose how "Open in Terminal" behaves:
- New Tab: Creates a new tab named after the branch (default)
- Split: Adds to current tab as a split view

Changes:
- Add openTerminalMode setting to terminal state ('newTab' | 'split')
- Update terminal-view to respect the setting
- Add UI in Terminal Settings to toggle the behavior
- Rename pendingTerminalCwd to pendingTerminal with branch name

The new tab mode names tabs after the branch for easy identification.
The split mode is useful for comparing terminals side by side.

* feat(ui): display branch name in terminal header with git icon

- Move branch name display from tab name to terminal header
- Show full branch name (no truncation) with GitBranch icon
- Display branch name for both 'new tab' and 'split' modes
- Persist openTerminalMode setting to server and include in import/export
- Update settings dropdown to simplified "New Tab" label

* feat: add external terminal support with cross-platform detection

Add support for opening worktree directories in external terminals
(iTerm2, Warp, Ghostty, System Terminal, etc.) while retaining the
integrated terminal as the default option.

Changes:
- Add terminal detection for macOS, Windows, and Linux
- Add "Open in Terminal" split-button in worktree dropdown
- Add external terminal selection in Settings > Terminal
- Add default open mode setting (new tab vs split)
- Display branch name in terminal panel header
- Support 20+ terminals across platforms

Part of #558, Closes #550

* fix: address PR review comments

- Add nonce parameter to terminal navigation to allow reopening same
  worktree multiple times
- Fix shell path escaping in editor.ts using single-quote wrapper
- Add validatePathParams middleware to open-in-external-terminal route
- Remove redundant validation block from createOpenInExternalTerminalHandler
- Remove unused pendingTerminal state and setPendingTerminal action
- Remove unused getTerminalInfo function from editor.ts

* fix: address PR review security and validation issues

- Add runtime type check for worktreePath in open-in-terminal handler
- Fix Windows Terminal detection using commandExists before spawn
- Fix xterm shell injection by using sh -c with escapeShellArg
- Use loose equality for null/undefined in useEffectiveDefaultTerminal
- Consolidate duplicate imports from open-in-terminal.js

* chore: update package-lock.json

* fix: use response.json() to prevent disposal race condition in E2E test

Replace response.body() with response.json() in open-existing-project.spec.ts
to fix the "Response has been disposed" error. This matches the pattern used
in other test files.

* Revert "fix: use response.json() to prevent disposal race condition in E2E test"

This reverts commit 36bdf8c24a.

* fix: address PR review feedback for terminal feature

- Add explicit validation for worktreePath in createOpenInExternalTerminalHandler
- Add aria-label to refresh button in terminal settings for accessibility
- Only show "no terminals" message when not refreshing
- Reset initialCwdHandledRef on failure to allow retries
- Use z.coerce.number() for nonce URL param to handle string coercion
- Preserve branchName when creating layout for empty tab
- Update getDefaultTerminal return type to allow null result

---------

Co-authored-by: Kacper <kacperlachowiczwp.pl@wp.pl>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 10:22:26 +01:00
Shirone
e73c92b031 Merge pull request #582 from stefandevo/fix/e2e-response-disposal-race
fix: prevent response disposal race condition in E2E test
2026-01-19 00:02:04 +00:00
Web Dev Cody
09151aa3c8 Merge pull request #590 from AutoMaker-Org/automode-api
feat: implement cursor model migration and enhance auto mode function…
2026-01-18 18:59:59 -05:00
Shirone
d6300f33ca fix: skip PR assignment for main worktree and refine metadata fallback logic
This update modifies the list handler to skip PR assignment for the main worktree, preventing confusion when displaying PRs on the main branch tab. Additionally, the fallback logic for assigning stored metadata is refined to only apply if the PR state is 'OPEN', ensuring more accurate representation of PRs.
2026-01-19 00:49:56 +01:00
webdevcody
4b0d1399b1 feat: implement cursor model migration and enhance auto mode functionality
This commit introduces significant updates to the cursor model handling and auto mode features. The cursor model IDs have been standardized to a canonical format, ensuring backward compatibility while migrating legacy IDs. New endpoints for starting and stopping the auto mode loop have been added, allowing for better control over project-specific auto mode operations.

Key changes:
- Updated cursor model IDs to use the 'cursor-' prefix for consistency.
- Added new API endpoints: `/start` and `/stop` for managing auto mode.
- Enhanced the status endpoint to provide detailed project-specific auto mode information.
- Improved error handling and logging throughout the auto mode service.
- Migrated legacy model IDs to their canonical counterparts in various components.

This update aims to streamline the user experience and ensure a smooth transition for existing users while providing new functionalities.
2026-01-18 18:42:52 -05:00
Stefan de Vogelaere
55a34a9f1f feat: add auto-login for dev mode and fix log box formatting (#567)
* feat: add auto-login for dev mode and fix log box formatting

Add AUTOMAKER_AUTO_LOGIN environment variable that, when set to 'true',
automatically creates a session for web mode users without requiring
them to enter the API key. Useful for development environments.

Also fix formatting issues in console log boxes:
- API Key box: add right border, show auto-login status and tips
- Claude auth warning: add separator line, fix emoji spacing
- Server info box: use consistent 71-char width, proper padding
- Port conflict error: use same width, proper dynamic padding

Environment variables:
- AUTOMAKER_AUTO_LOGIN=true: Skip login prompt, auto-create session
- AUTOMAKER_API_KEY: Use a fixed API key (existing)
- AUTOMAKER_HIDE_API_KEY=true: Hide the API key banner (existing)

* fix: add production safeguard to auto-login and extract log box constant

- Add NODE_ENV !== 'production' check to prevent auto-login in production
- Extract magic number 67 to BOX_CONTENT_WIDTH constant in auth.ts and index.ts
- Document AUTOMAKER_AUTO_LOGIN env var in CLAUDE.md and README.md
2026-01-18 23:48:00 +01:00
Stefan de Vogelaere
c4652190eb feat: add three viewing modes for app specification (#566)
* feat: add three viewing modes for app specification

Introduces View, Edit, and Source modes for the spec page:

- View: Clean read-only display with cards, badges, and accordions
- Edit: Structured form-based editor for all spec fields
- Source: Raw XML editor for advanced users

Also adds @automaker/spec-parser shared package for XML parsing
between server and client.

* fix: address PR review feedback

- Replace array index keys with stable UUIDs in array-field-editor,
  features-section, and roadmap-section components
- Replace regex-based XML parsing with fast-xml-parser for robustness
- Simplify renderContent logic in spec-view by removing dead code paths

* fix: convert git+ssh URLs to https in package-lock.json

* fix: address PR review feedback for spec visualiser

- Remove unused RefreshCw import from spec-view.tsx
- Add explicit parsedSpec check in renderContent for robustness
- Hide save button in view mode since it's read-only
- Remove GripVertical drag handles since drag-and-drop is not implemented
- Rename Map imports to MapIcon to avoid shadowing global Map
- Escape tagName in xml-utils.ts RegExp functions for safety
- Add aria-label attributes for accessibility on mode tabs

* fix: address additional PR review feedback

- Fix Textarea controlled/uncontrolled warning with default value
- Preserve IDs in useEffect sync to avoid unnecessary remounts
- Consolidate lucide-react imports
- Add JSDoc note about tag attributes limitation in xml-utils.ts
- Remove redundant disabled prop from SpecModeTabs
2026-01-18 23:45:43 +01:00
Web Dev Cody
af95dae73a Merge pull request #574 from stefandevo/fix/v0.13.0rc
fix: use getTerminalFontFamily for dev server logs terminal font
2026-01-18 17:17:44 -05:00
Web Dev Cody
1c1d9d30a7 Merge pull request #583 from stefandevo/fix/initial-theme
fix: prevent new projects from overriding global theme setting
2026-01-18 17:17:20 -05:00
webdevcody
3faebfa3fe refactor: update migration process to selectively copy specific application data files
This commit refines the migration functionality in the SettingsService to focus on migrating only specific application data files from the legacy Electron userData directory. The migration now explicitly handles files such as settings.json, credentials.json, and agent-sessions, while excluding internal caches. Enhanced logging provides clearer insights into the migration process, including skipped items and errors encountered.

Key changes:
- Modified migration logic to target specific application data files and directories.
- Improved logging for migration status and error handling.
- Introduced a new private method, `copyDirectory`, to facilitate directory copying.
2026-01-18 16:29:55 -05:00
webdevcody
d0eaf0e51d feat: enhance migration process to copy entire data directory from legacy Electron userData location
This update expands the migration functionality in the SettingsService to include the entire data directory, rather than just specific files. The migration now handles all files and directories, including settings.json, credentials.json, sessions-metadata.json, and conversation histories. Additionally, logging has been improved to reflect the migration of all items and to provide clearer information on the migration process.

Key changes:
- Updated migration logic to recursively copy all contents from the legacy directory.
- Enhanced logging for migration status and errors.
- Added a new private method, `copyDirectoryContents`, to facilitate the recursive copying of files and directories.
2026-01-18 16:25:25 -05:00
webdevcody
da80729f56 feat: implement migration of settings from legacy Electron userData directory
This commit introduces a new feature in the SettingsService to migrate user settings from the legacy Electron userData directory to the new shared data directory. The migration process checks for the existence of settings in both locations and handles the transfer of settings.json and credentials.json files if necessary. It also includes logging for successful migrations and any errors encountered during the process, ensuring a smooth transition for users upgrading from previous versions.

Key changes:
- Added `migrateFromLegacyElectronPath` method to handle migration logic.
- Implemented platform-specific paths for legacy settings based on the operating system.
- Enhanced error handling and logging for migration operations.
2026-01-18 16:10:04 -05:00
DhanushSantosh
749fb3a5c1 fix: add token query parameter support to auth middleware for web mode image loading
The /api/fs/image endpoint requires authentication, but when loading images via
CSS background-image or img tags, only query parameters can be used (headers
cannot be set). Web mode passes the session token as a query parameter (?token=...),
but the auth middleware didn't recognize it, causing image requests to fail.

This fix adds support for the 'token' query parameter in the checkAuthentication
function, allowing the auth middleware to validate web mode session tokens when
they're passed as query parameters.

Now image loads work correctly in web mode by:
1. Client passes session token in URL: ?token={sessionToken}
2. Auth middleware recognizes and validates the token query parameter
3. Image endpoint successfully serves the image after authentication

This fixes the issue where kanban board background images were not visible
in web mode.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-18 21:23:18 +05:30
DhanushSantosh
dd26de9f55 fix: add authentication validation to image endpoint for web mode
Adds authentication checks to the /api/fs/image endpoint to validate
session tokens in web mode. This ensures background images and other
image assets load correctly in web mode by validating:
- session token from query parameter (web mode)
- API key from query parameter (Electron mode)
- session cookie (web mode fallback)
- X-API-Key and X-Session-Token headers

This fixes the issue where kanban board background images were not
visible in web mode because the image request lacked proper authentication.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-18 21:13:10 +05:30
Stefan de Vogelaere
b6cb926cbe fix: also remove theme calculation from dashboard-view
Missed this code path which is used when opening projects from the
dashboard after completing setup.
2026-01-18 16:18:58 +01:00
Stefan de Vogelaere
eb30ef71f9 fix: prevent response disposal race condition in E2E test
Wrap route.fetch() and response.json() in try/catch blocks to handle
cases where the response is disposed before it can be accessed. Falls
back to route.continue() to let the original request proceed normally.

This fixes the intermittent "Response has been disposed" error in
open-existing-project.spec.ts that occurs due to timing issues in CI.
2026-01-18 16:13:53 +01:00
Stefan de Vogelaere
75fe579e93 fix: prevent new projects from overriding global theme setting
When creating new projects, the theme was always explicitly set even when
matching the global theme. This caused "Use Global Theme" to be unchecked,
preventing global theme changes from affecting the project.

Now theme is only set on new projects when explicitly provided or when
recovering a trashed project's theme preference.
2026-01-18 16:12:32 +01:00
Stefan de Vogelaere
8ab9dc5a11 fix: use user's terminal font settings for dev server logs
XtermLogViewer was passing DEFAULT_TERMINAL_FONT directly to xterm.js,
but this value is 'default' - a sentinel string for the dropdown selector,
not a valid CSS font family. Also the font size was hardcoded to 13px.

Now reads the user's font preference from terminalState:
- fontFamily: Uses getTerminalFontFamily() to convert to CSS font stack
- defaultFontSize: Uses store value when fontSize prop not provided

Also adds useEffects to update font settings dynamically when they change.

This ensures dev server logs respect Settings > Terminal settings.
2026-01-18 15:22:21 +01:00
Dhanush Santosh
96202d4bc2 Merge pull request #573 from DhanushSantosh/patchcraft
fix: resolve data directory persistence between Electron and Web modes
2026-01-18 19:36:09 +05:30
DhanushSantosh
f68aee6a19 fix: prevent response disposal race condition in E2E test 2026-01-18 19:29:32 +05:30
DhanushSantosh
7795d81183 merge: resolve conflicts with upstream/v0.13.0rc 2026-01-18 19:21:56 +05:30
Dhanush Santosh
0c053dab48 Merge pull request #578 from stefandevo/fix/v0.13.0rc-e2e-ci
fix: improve project-switcher data-testid for uniqueness and special chars
2026-01-18 19:14:32 +05:30
Stefan de Vogelaere
1ede7e7e6a refactor: extract sanitizeForTestId to shared utility
Address PR review comments by:
- Creating shared sanitizeForTestId utility in apps/ui/src/lib/utils.ts
- Updating ProjectSwitcherItem to use the shared utility
- Adding matching helper to test utils for E2E tests
- Updating all E2E tests to use the sanitization helper

This ensures the component and tests use identical sanitization logic,
making tests robust against project names with special characters.
2026-01-18 14:36:31 +01:00
DhanushSantosh
980006d40e fix: use setItem helper and safer Playwright selector in tests
- Replace direct localStorage.setItem() with setItem helper in use-settings-migration.ts (line 472) for consistent storage-availability checks and error handling
- Replace brittle attribute selector with Playwright's getByRole in open-existing-project.spec.ts (line 162) to handle names containing special characters

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-18 19:06:07 +05:30
Stefan de Vogelaere
ef2dcbacd4 fix: improve project-switcher data-testid for uniqueness and special chars
The data-testid generation was using only the sanitized project name which
could produce collisions and didn't handle special characters properly.

Changes:
- Combine stable project.id with sanitized name: project-switcher-{id}-{name}
- Expand sanitization to remove non-alphanumeric chars (except hyphens)
- Collapse multiple hyphens and trim leading/trailing hyphens
- Update E2E tests to use ends-with selector for matching

This ensures test IDs are deterministic, unique, and safe for CSS selectors.
2026-01-18 14:29:04 +01:00
DhanushSantosh
505a2b1e0b docs: enhance docstrings to reach 80% coverage threshold
- Expanded docstrings in use-settings-migration.ts for parseLocalStorageSettings, localStorageHasMoreData, mergeSettings, and performSettingsMigration
- Expanded docstrings in use-settings-sync.ts for getSettingsFieldValue and hasSettingsFieldChanged helper functions
- Added detailed parameter and return value documentation
- Improved clarity on migration flow and settings merging logic

This brings docstring coverage from 77.78% to 80%+ to satisfy CodeRabbit checks.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-18 18:42:41 +05:30
DhanushSantosh
2e57553639 Merge remote-tracking branch 'upstream/v0.13.0rc' into patchcraft 2026-01-18 18:21:34 +05:30
DhanushSantosh
f37812247d fix: resolve data directory persistence between Electron and Web modes
This commit fixes bidirectional data synchronization between Electron and Web
modes by addressing multiple interconnected issues:

**Core Fixes:**

1. **Electron userData Path (main.ts)**
   - Explicitly set userData path in development using app.setPath()
   - Navigate from __dirname to project root instead of relying on process.cwd()
   - Ensures Electron reads from /data instead of ~/.config/Automaker

2. **Server DataDir Path (main.ts, start-automaker.sh)**
   - Fixed startServer() to use __dirname for reliable path calculation
   - Export DATA_DIR environment variable in start-automaker.sh
   - Server now consistently uses shared /data directory

3. **Settings Sync Protection (settings-service.ts)**
   - Modified wipe protection to distinguish legitimate removals from accidents
   - Allow empty projects array if trashedProjects has items
   - Prevent false-positive wipe detection when removing projects

4. **Diagnostics & Logging**
   - Enhanced cache loading logging in use-settings-migration.ts
   - Detailed migration decision logs for troubleshooting
   - Track project counts from both cache and server

**Impact:**
- Projects created in Electron now appear in Web mode after restart
- Projects removed in Web mode stay removed in Electron after restart
- Settings changes sync bidirectionally across mode switches
- No more data loss or project duplication issues

**Testing:**
- Verified Electron uses /home/dhanush/Projects/automaker/data
- Confirmed server startup logs show correct DATA_DIR
- Tested project persistence across mode restarts
- Validated no writes to ~/.config/Automaker in dev mode

Fixes: Data persistence between Electron and Web modes

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-18 18:21:14 +05:30
Stefan de Vogelaere
53298106e9 feat: add Claude API provider profiles for alternative endpoints
Add support for managing multiple Claude-compatible API endpoints
(z.AI GLM, AWS Bedrock, etc.) through provider profiles in settings.

Features:
- New ClaudeApiProfile type with base URL, API key, model mappings
- Pre-configured z.AI GLM template with correct model names
- Profile selector in Settings > Claude > API Profiles
- Clean switching between profiles and direct Anthropic API
- Immediate persistence to prevent data loss on restart

Profile support added to all execution paths:
- Agent service (chat)
- Ideation service
- Auto-mode service (feature agents, enhancements)
- Simple query service (title generation, descriptions, etc.)
- Backlog planning, commit messages, spec generation
- GitHub issue validation, suggestions

Environment variables set when profile is active:
- ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN/API_KEY
- ANTHROPIC_DEFAULT_HAIKU/SONNET/OPUS_MODEL
- API_TIMEOUT_MS, CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC
2026-01-18 13:50:41 +01:00
DhanushSantosh
484d4c65d5 fix: use shared data directory for Electron and web modes
CRITICAL: Electron was using ~/.config/@automaker/app/data/ while web mode
used ./data/, causing projects to never sync between modes.

In development mode, both now use the shared project root ./data directory.
In production, Electron uses its isolated userData directory for app portability.

This ensures:
- Electron projects sync to the same server data directory as web mode
- Projects opened in Electron immediately appear in web mode
- Server restart doesn't lose projects from either mode

The issue was on line 487 where DATA_DIR was set to app.getPath('userData')
instead of the shared project ./data directory.

Fixes the fundamental problem where projects never appeared in web mode
even though they were in the server's settings file.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-18 16:25:35 +05:30
Shirone
327aef89a2 Merge pull request #562 from AutoMaker-Org/feature/v0.12.0rc-1768688900786-5ea1
refactor: standardize PR state representation across the application
2026-01-18 10:45:59 +00:00
Shirone
44e665f1bf fix: adress pr comments 2026-01-18 00:22:27 +01:00
Shirone
5b1e0105f4 refactor: standardize PR state representation across the application
Updated the PR state handling to use a consistent uppercase format ('OPEN', 'MERGED', 'CLOSED') throughout the codebase. This includes changes to the worktree metadata interface, PR creation logic, and related tests to ensure uniformity and prevent potential mismatches in state representation.

Additionally, modified the GitHub PR fetching logic to retrieve all PR states, allowing for better detection of state changes.

This refactor enhances clarity and consistency in how PR states are managed and displayed.
2026-01-17 23:58:19 +01:00
webdevcody
832d10e133 refactor: replace Loader2 with Spinner component across the application
This update standardizes the loading indicators by replacing all instances of Loader2 with the new Spinner component. The Spinner component provides a consistent look and feel for loading states throughout the UI, enhancing the user experience.

Changes include:
- Updated loading indicators in various components such as popovers, modals, and views.
- Ensured that the Spinner component is used with appropriate sizes for different contexts.

No functional changes were made; this is purely a visual and structural improvement.
2026-01-17 17:58:16 -05:00
DhanushSantosh
7b7ac72c14 fix: use shared data directory for Electron and web modes
CRITICAL FIX: Electron and web mode were using DIFFERENT data directories:
- Electron: Docker volume 'automaker-data' (isolated from host)
- Web: Local ./data directory (host filesystem)

This caused projects opened in Electron to never appear in web mode because
they were synced to a completely separate Docker volume.

Solution: Mount the host's ./data directory into both containers
This ensures Electron and web mode always share the same data directory
and all projects are immediately visible across modes.

Now when you:
1. Open projects in Electron → synced to ./data
2. Switch to web mode → loads from same ./data
3. Restart server → both see the same projects

Fixes issue where projects opened in Electron don't appear in web mode.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-18 03:06:09 +05:30
DhanushSantosh
9137f0e75f fix: keep localStorage cache in sync with server settings
When switching between Electron and web modes or when the server temporarily
stops, web mode was falling back to stale localStorage data instead of fresh
server data.

This fix:
1. Updates localStorage cache whenever fresh server settings are fetched
2. Updates localStorage cache whenever settings are synced to server
3. Prioritizes fresh settings cache over old Zustand persisted storage

This ensures that:
- Web mode always sees the latest projects even after mode switches
- Switching from Electron to web mode immediately shows new projects
- Server restarts don't cause web mode to use stale cached data

Fixes issue where projects opened in Electron didn't appear in web mode
after stopping and restarting the server.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-18 02:46:31 +05:30
DhanushSantosh
b66efae5b7 fix: sync projects immediately instead of debouncing
Projects are critical data that must persist across mode switches (Electron/web).
Previously, project changes were debounced by 1 second, which could cause data
loss if:
1. User switched from Electron to web mode quickly
2. App closed before debounce timer fired
3. Network temporarily unavailable during debounce window

This change makes project array changes sync immediately (syncNow) instead of
using the 1-second debounce, ensuring projects are always persisted to the
server right away and visible in both Electron and web modes.

Fixes issue where projects opened in Electron didn't appear in web mode.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-18 02:30:16 +05:30
DhanushSantosh
2a8706e714 fix: add session token to image URLs for web mode authentication
In web mode, image loads may not send session cookies due to proxy/CORS
restrictions. This adds the session token as a query parameter to ensure
images load correctly with proper authentication in web mode.

Fixes custom project icons and images not loading in web mode.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-18 02:21:47 +05:30
DhanushSantosh
174c02cb79 fix: automatically remove projects with non-existent paths
When a project fails to initialize because the directory no longer exists
(e.g., test artifacts, deleted folders), automatically remove it from the
project list instead of showing the error repeatedly on every reload.

This prevents users from being stuck with broken project references in their
settings after testing or when project directories are moved/deleted.

The user is notified with a toast message explaining the removal.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-18 02:09:28 +05:30
DhanushSantosh
a7f7898ee4 fix: persist session token to localStorage for web mode page reload survival
Web mode sessions were being lost on page reload because the session token was
stored only in memory (cachedSessionToken). When the page reloaded, the token
was cleared and verifySession() would fail, redirecting users to login.

This commit adds localStorage persistence for the session token, ensuring:
1. Token survives page reloads in web mode
2. verifySession() can use the persisted token from localStorage
3. Token is cleared properly on logout
4. Graceful fallback if localStorage is unavailable (SSR, disabled storage)

The HTTP-only cookie alone isn't sufficient for web mode due to SameSite cookie
restrictions and potential proxy issues with credentials forwarding.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-18 02:02:10 +05:30
DhanushSantosh
fdad82bf88 fix: enable WebSocket proxying in Vite dev server
Enables ws: true for /api proxy to properly forward WebSocket connections through the development server in web mode. This ensures real-time features work correctly when developing in browser mode.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-18 01:52:11 +05:30
DhanushSantosh
b0b49764b9 fix: add localhost to CORS_ORIGIN for web mode development
The web mode launcher was setting CORS_ORIGIN to only include the system
hostname and 127.0.0.1, but users access via http://localhost:3007 which
wasn't in the allowed list.

Now includes:
- http://localhost:3007 (primary dev URL)
- http://$HOSTNAME:3007 (system hostname if needed)
- http://127.0.0.1:3007 (loopback IP)

Also cleaned up debug logging from CORS check since root cause is now clear.

Fixes: Persistent "Not allowed by CORS" errors in web mode

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-18 01:50:41 +05:30
DhanushSantosh
e10cb83adc debug: add CORS logging to diagnose origin rejection
Added detailed logging to see:
- What origin is being sent
- How the hostname is parsed
- Why origins are being accepted/rejected

This will help us understand why CORS is still failing in web mode.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-18 01:47:53 +05:30
DhanushSantosh
b8875f71a5 fix: improve CORS configuration to handle localhost and private IPs
The CORS check was too strict for local development. Changed to:
- Parse origin URL properly to extract hostname
- Allow all localhost origins (any port)
- Allow all 127.0.0.1 origins (loopback IP)
- Allow all private network IPs (192.168.x.x, 10.x.x.x, 172.x.x.x)
- Keep security by rejecting unknown origins

This fixes CORS errors when accessing from http://localhost:3007
or other local addresses during web mode development.

Fixes: "Not allowed by CORS" errors in web mode

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-18 01:45:10 +05:30
DhanushSantosh
4186b80a82 fix: use relative URLs in web mode to leverage Vite proxy
In web mode, the API client was hardcoding localhost:3008, which bypassed
the Vite proxy and caused CORS errors. Now it uses relative URLs (just /api)
in web mode, allowing the proxy to handle routing and making requests appear
same-origin.

- Web mode: Use relative URLs for proxy routing (no CORS issues)
- Electron mode: Continue using hardcoded localhost:3008

This allows the Vite proxy configuration to actually work in web mode.

Fixes: Persistent CORS errors in web mode development

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-18 01:41:21 +05:30
DhanushSantosh
7eae0215f2 chore: update package-lock.json 2026-01-18 01:38:09 +05:30
DhanushSantosh
4cd84a4734 fix: add API proxy to Vite dev server for web mode CORS
When running in web mode (npm run dev:web), the frontend on localhost:3007
was making cross-origin requests to the backend on localhost:3008, causing
CORS errors.

Added Vite proxy configuration to forward /api requests from the dev server
to the backend. This makes all API calls appear same-origin to the browser,
eliminating CORS blocks during development.

Now web mode users can access http://localhost:3007 without CORS errors.

Fixes: CORS "Not allowed by CORS" errors in web mode

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-18 01:37:49 +05:30
DhanushSantosh
044c3d50d1 fix: mark dmg-license as optional dependency for cross-platform builds
dmg-license is a macOS-only package used for building DMG installers.
Moving it from devDependencies to optionalDependencies allows npm ci
to succeed on Linux and Windows without failing on platform checks.

macOS developers will still get the package when available.
Linux/Windows developers can now run npm ci without errors.

Fixes: npm ci failing on Linux with "EBADPLATFORM" error

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-18 01:28:14 +05:30
Shirone
a1de0a78a0 Merge pull request #545 from stefandevo/fix/sandbox-warning-persistence
fix: sandbox warning persistence and add env var option
2026-01-17 18:57:19 +00:00
Shirone
fef9639e01 Merge pull request #539 from stefandevo/fix/light-mode-agent-output
fix: respect theme in agent output modal and log viewer
2026-01-17 18:35:32 +00:00
Stefan de Vogelaere
aef479218d fix: use DEFAULT_FONT_VALUE for initial terminal font
The initial terminalState.fontFamily was set to a raw font string
that didn't match any option in TERMINAL_FONT_OPTIONS, causing the
dropdown to appear empty. Changed to use DEFAULT_FONT_VALUE sentinel.
2026-01-17 19:32:42 +01:00
Stefan de Vogelaere
ded5ecf4e9 refactor: reduce code duplication in font settings and sync logic
Address CodeRabbit review feedback:
- Create getEffectiveFont helper to deduplicate getEffectiveFontSans/Mono
- Extract getSettingsFieldValue and hasSettingsFieldChanged helpers
- Create reusable FontSelector component for font selection UI
- Refactor project-theme-section and appearance-section to use FontSelector
2026-01-17 19:30:00 +01:00
Stefan de Vogelaere
a01f299597 fix: resolve type errors after merging upstream v0.12.0rc
- Fix ThemeMode type casting in __root.tsx
- Use specRegeneration.create() instead of non-existent generateAppSpec
- Add missing keyboard shortcut entries for projectSettings and notifications
- Fix lucide-react type casts with intermediate unknown cast
- Remove unused pipelineConfig prop from ListRow component
- Align SettingsProject interface with Project type
- Fix defaultDeleteBranchWithWorktree property name
2026-01-17 19:20:49 +01:00
Stefan de Vogelaere
21c9e88a86 Merge remote-tracking branch 'upstream/v0.12.0rc' into fix/light-mode-agent-output 2026-01-17 19:10:49 +01:00
Shirone
af17f6e36f Merge pull request #535 from stefandevo/v0.12.0rc
feat: add font customization and 8 new themes
2026-01-17 18:06:04 +00:00
Stefan de Vogelaere
e69a2ad722 docs: add AUTOMAKER_SKIP_SANDBOX_WARNING env var documentation
Document the new environment variable in README.md and .env.example
2026-01-17 18:33:08 +01:00
DhanushSantosh
0480f6ccd6 fix: handle dynamic model IDs with slashes in the model name
isOpencodeModel was rejecting valid dynamic model IDs like
'openrouter/qwen/qwen3-14b:free' because it was splitting on all slashes
and expecting exactly 2 parts. This caused valid OpenCode models to be
treated as unknown, falling back to Claude.

Now correctly splits on the FIRST slash only, allowing model names
like 'qwen/qwen3-14b:free' to be recognized as valid.

Fixes: User selects openrouter/qwen/qwen3-14b:free → server falls back to Claude

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-17 21:13:47 +05:30
DhanushSantosh
24042d20c2 fix: filter dynamic OpenCode models by enabled status in model selector
The phase model selector was showing ALL discovered dynamic models regardless
of whether they were enabled in settings. Now it filters dynamic models by
enabledDynamicModelIds, matching the behavior of Cursor models and making
the enable/disable setting meaningful.

Users can now:
- Disable models in settings they don't want to use
- See only enabled dynamic models in the model selector dropdown
- Have the "Select all" checkbox properly control which models appear

This ensures consistency: enabling/disabling models in settings affects
which models are available for feature execution.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-17 21:00:22 +05:30
DhanushSantosh
9c3b3a4104 fix: make dynamic models select-all checkbox respect search filters
The "Select all" checkbox for dynamic models was using the unfiltered models list,
causing the checkbox state to not reflect what users see when searching. Now it
correctly operates on the filtered models list so:

- Checkbox state matches the visible filtered models
- "Select all" only toggles models the user can see
- Indeterminate state shows if some filtered models are selected

This ensures the checkbox has a meaningful purpose when filtering/searching models.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-17 20:32:10 +05:30
Stefan de Vogelaere
17e2cdfc85 fix: sandbox warning persistence and add env var option
Fix race condition where sandbox warning appeared on every refresh
even after checking "Do not show again". The issue was that the
sandbox check effect ran before settings were hydrated from the
server, so skipSandboxWarning was always false (the default).

Changes:
- Add settingsLoaded to sandbox check dependencies to ensure the
  user's preference is loaded before checking
- Add AUTOMAKER_SKIP_SANDBOX_WARNING env var option to skip the
  warning entirely (useful for dev/CI environments)
2026-01-17 15:33:51 +01:00
DhanushSantosh
466c34afd4 ci: improve release workflow artifact uploads
- Use explicit file patterns to exclude builder config/debug files (builder-*.yml, *.yaml)
- Include blockmap files for efficient delta updates in auto-update scenarios
- Ensure only production-ready artifacts are uploaded to GitHub releases

This prevents accidental inclusion of builder configuration files in the release assets.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-17 19:18:15 +05:30
Shirone
b9567f5904 Merge pull request #542 from stefandevo/fix/api-key-info-on-dev-restart
docs: add hint about AUTOMAKER_API_KEY env var to API key banner
2026-01-17 13:09:36 +00:00
Shirone
c2cf8ae892 Merge pull request #540 from stefandevo/fix/gh-not-in-git-folder
fix: stop repeated GitHub PR fetch warnings for non-GitHub repos
2026-01-17 13:08:28 +00:00
Stefan de Vogelaere
3aa3c10ea4 docs: add hint about AUTOMAKER_API_KEY env var to API key banner
When the dev server restarts, developers need to re-enter the API key
in the browser. While the key is persisted to ./data/.api-key, this
file may be missing in clean dev scenarios.

This adds a helpful tip to the API key banner informing developers
they can set AUTOMAKER_API_KEY environment variable for a persistent
API key during development, avoiding the need to re-enter it after
server restarts.
2026-01-17 13:53:34 +01:00
Stefan de Vogelaere
5cd4183a7b fix: use fresh timestamp when setting cache entry
Use Date.now() after checkGitHubRemote() completes instead of the
pre-captured timestamp to ensure accurate 5-minute TTL.
2026-01-17 12:36:33 +01:00
Stefan de Vogelaere
2d9e38ad99 fix: stop repeated GitHub PR fetch warnings for non-GitHub repos
When opening a git repository without a GitHub remote, the server logs
were spammed with warnings every 5 seconds during worktree polling:

  WARN [Worktree] Failed to fetch GitHub PRs: Command failed: gh pr list
  ... no git remotes found

This happened because fetchGitHubPRs() ran `gh pr list` without first
checking if the project has a GitHub remote configured.

Changes:
- Add per-project cache for GitHub remote status with 5-minute TTL
- Check cache before attempting to fetch PRs, skip silently if no remote
- Add forceRefreshGitHub parameter to clear cache on manual refresh
- Pass forceRefreshGitHub when user clicks the refresh worktrees button

This allows users to add a GitHub remote and immediately detect it by
clicking the refresh button, while preventing log spam during normal
polling for projects without GitHub remotes.
2026-01-17 12:32:42 +01:00
Shirone
93d73f6d26 Merge pull request #529 from AutoMaker-Org/feature/v0.12.0rc-1768603410796-o2fn
fix: UUID generation fail in docker env
2026-01-17 11:19:01 +00:00
Stefan de Vogelaere
5209395a74 fix: respect theme in agent output modal and log viewer
The Agent Output modal and LogViewer component had hardcoded dark zinc
colors that didn't adapt to light mode themes. Replaced all hardcoded
colors with semantic Tailwind classes (bg-popover, text-foreground,
text-muted-foreground, bg-muted, border-border) that automatically
respect the active theme.
2026-01-17 11:44:33 +01:00
DhanushSantosh
ef6b9ac2d2 fix: add --force flag to npm ci for platform-specific dependencies
npm ci without --force rejects platform-specific packages like dmg-license
which is macOS-only. The --force flag tells npm to proceed even when
platform constraints are violated.

This allows Linux containers to skip dmg-license and continue with the
install, matching the behavior we want for Docker development.
2026-01-17 15:53:31 +05:30
DhanushSantosh
92afbeb6bd fix: run npm install as root to avoid permission issues
The named Docker volume for node_modules is created with root ownership,
causing EACCES errors when npm tries to write as the automaker user.

Solution:
- Run npm ci as root (installation phase)
- Use --legacy-peer-deps to properly handle optional dependencies
- Fix permissions after install
- Run server process as automaker user for security

This eliminates permission denied errors during npm install in dev containers.
2026-01-17 15:36:50 +05:30
DhanushSantosh
bbdc11ce47 fix: improve docker-compose npm install permissions and use npm ci
Fixes permission denied errors when installing dependencies in Docker containers:

Changes:
- Remove stale node_modules directories before installing (fresh start)
- Use 'npm ci --force' instead of 'npm install --force' for deterministic installs
- Add chmod to ensure writable permissions on node_modules
- Properly fix directory ownership and permissions before install

This prevents EACCES errors when multiple processes try to write to node_modules
and handles lingering permission issues from previous failed container runs.
2026-01-17 15:30:21 +05:30
DhanushSantosh
545bf2045d fix: add --force flag to npm install in docker-compose files
Allow npm to install platform-specific devDependencies (like dmg-license
which is macOS-only) by skipping platform checks in Linux Docker containers.
This matches the behavior already used in CI workflows.

Fixes Docker container startup failure:
- docker-compose.dev.yml (full stack development)
- docker-compose.dev-server.yml (server-only with local Electron)

The --force flag allows npm to proceed with installation even when some
optional/platform-specific dependencies can't be installed on the current
platform.
2026-01-17 15:00:48 +05:30
DhanushSantosh
a0471098fa fix: use specific data-testid selectors in project switcher assertions
Replace generic getByRole('button', { name: /.../ }) selectors with specific
getByTestId('project-switcher-project-') to avoid strict mode
violations where the selector resolves to multiple elements (project switcher
button and sidebar button).

Fixes failing E2E tests:
- feature-manual-review-flow.spec.ts
- new-project-creation.spec.ts
- open-existing-project.spec.ts
2026-01-17 14:53:06 +05:30
Stefan de Vogelaere
3320b40d15 feat: align terminal font settings with appearance fonts
- Terminal font dropdown now uses mono fonts from UI font options
- Unified font list between appearance section and terminal settings
- Terminal font persisted to GlobalSettings for import/export support
- Aligned global terminal settings popover with per-terminal popover:
  - Same settings in same order (Font Size, Run on New Terminal, Font Family, Scrollback, Line Height, Screen Reader)
  - Consistent styling (Radix Select instead of native select)
- Added terminal padding (12px vertical, 16px horizontal) for readability
2026-01-17 10:18:11 +01:00
DhanushSantosh
bac5e1c220 Merge upstream/v0.12.0rc into feature/fedora-rpm-support
Resolved conflict in backlog-plan/common.ts:
- Kept local (stricter) validation: Array.isArray(parsed?.result?.changes)
- This ensures type safety for the changes array
2026-01-17 14:44:37 +05:30
DhanushSantosh
33fa138d21 feat: add docker group support with sg docker command
Improve Docker access handling by detecting and using 'sg docker' command
when the user is in the docker group but hasn't logged out yet. This allows
running docker commands without requiring a full session restart after
`usermod -aG docker $USER`.

Changes:
- Detect docker group access and fall back to sg docker -c when needed
- Export DOCKER_CMD variable for use throughout the script
- Update all docker compose and docker ps commands to use DOCKER_CMD
- Improve error messages to guide users on fixing docker access issues
2026-01-17 14:40:08 +05:30
DhanushSantosh
bc09a22e1f fix: extract app version from apps/ui/package.json instead of monorepo root
The start-automaker.sh script now correctly sources the app version (0.12.0)
from apps/ui/package.json instead of the monorepo version (1.0.0) from the
root package.json. This ensures the launcher displays the correct Automaker
application version.
2026-01-17 14:19:12 +05:30
Stefan de Vogelaere
b771b51842 fix: address code review feedback
- Fix git+ssh URL to git+https for @electron/node-gyp (build compatibility)
- Remove duplicate @fontsource packages from root package.json
- Refactor font state initialization to reduce code duplication
2026-01-17 09:15:35 +01:00
Stefan de Vogelaere
1a7bf27ead feat: add new themes, Zed fonts, and sort theme/font lists
New themes added:
- Dark: Ayu Dark, Ayu Mirage, Ember, Matcha
- Light: Ayu Light, One Light, Bluloco, Feather

Other changes:
- Bundle Zed Sans and Zed Mono fonts from zed-industries/zed-fonts
- Sort font options alphabetically (default first)
- Sort theme options alphabetically (Dark/Light first)
- Improve Ayu Dark text contrast for better readability
- Fix Matcha theme to have green undertone instead of blue
2026-01-17 09:15:35 +01:00
Stefan de Vogelaere
f3b00d0f78 feat: add global font settings with per-project override
- Add fontFamilySans and fontFamilyMono to GlobalSettings type
- Add global font state and actions to app store
- Update getEffectiveFontSans/Mono to fall back to global settings
- Add font selectors to global Settings → Appearance
- Add "Use Global Font" checkboxes in Project Settings → Theme
- Add fonts to settings sync and migration
- Include fonts in import/export JSON
2026-01-17 09:15:34 +01:00
Stefan de Vogelaere
c747baaee2 fix: use sentinel value for default font selection
Radix UI Select doesn't allow empty string values, so use 'default'
as a sentinel value instead.
2026-01-17 09:15:34 +01:00
Stefan de Vogelaere
1322722db2 feat: add per-project font override settings
Add font selectors that allow per-project font customization for both
sans and mono fonts, independent of theme selection. Uses system fonts.

- Add fontFamilySans and fontFamilyMono to ProjectSettings and Project types
- Create ui-font-options.ts config with system font options
- Add store actions: setProjectFontSans, setProjectFontMono, getEffectiveFontSans, getEffectiveFontMono
- Apply font CSS variables in root component
- Add font selector UI in project-theme-section (Project Settings → Theme)
2026-01-17 09:15:34 +01:00
webdevcody
aa35eb3d3a feat: implement spec synchronization feature for improved project management
- Added a new `/sync` endpoint to synchronize the project specification with the current codebase and feature state.
- Introduced `syncSpec` function to handle the synchronization logic, updating technology stack, implemented features, and roadmap phases.
- Enhanced the running state management to track synchronization tasks alongside existing generation tasks.
- Updated UI components to support synchronization actions, including loading indicators and status updates.
- Improved logging and error handling for better visibility during sync operations.

These changes enhance project management capabilities by ensuring that the specification remains up-to-date with the latest code and feature developments.
2026-01-17 01:45:45 -05:00
webdevcody
616e2ef75f feat: add HOSTNAME and VITE_HOSTNAME support for improved server URL configuration
- Introduced `HOSTNAME` environment variable for user-facing URLs, defaulting to localhost.
- Updated server and client code to utilize `HOSTNAME` for constructing URLs instead of hardcoded localhost.
- Enhanced documentation in CLAUDE.md to reflect new configuration options.
- Added `VITE_HOSTNAME` for frontend API URLs, ensuring consistent hostname usage across the application.

These changes improve flexibility in server configuration and enhance the user experience by providing accurate URLs.
2026-01-16 22:40:36 -05:00
webdevcody
d98cae124f feat: enhance sidebar functionality for mobile and compact views
- Introduced a floating toggle button for mobile to show/hide the sidebar when collapsed.
- Updated sidebar behavior to completely hide on mobile when the new mobileSidebarHidden state is true.
- Added logic to conditionally render sidebar components based on screen size using the new useIsCompact hook.
- Enhanced SidebarHeader to include close and expand buttons for mobile views.
- Refactored CollapseToggleButton to hide in compact mode.
- Implemented HeaderActionsPanel for mobile actions in various views, improving accessibility and usability on smaller screens.

These changes improve the user experience on mobile devices by providing better navigation options and visibility controls.
2026-01-16 22:27:19 -05:00
Web Dev Cody
26aaef002d Merge pull request #537 from AutoMaker-Org/claude/issue-536-20260117-0132
feat: add configurable host binding for server and Vite dev server
2026-01-16 21:22:34 -05:00
claude[bot]
09bb59d090 feat: add configurable host binding for server and Vite dev server
- Add HOST environment variable (default: 0.0.0.0) to allow binding to specific network interfaces
- Update server to listen on configurable host instead of hardcoded localhost
- Update Vite dev server to respect HOST environment variable
- Enhanced server startup banner to display listening address
- Updated .env.example and CLAUDE.md documentation

Fixes #536

Co-authored-by: Web Dev Cody <webdevcody@users.noreply.github.com>
2026-01-17 01:34:06 +00:00
Shirone
2f38ffe2d5 Merge pull request #532 from AutoMaker-Org/feature/v0.12.0rc-1768605251997-8ufb
fix: feature.json corruption on crash lose
2026-01-17 00:00:18 +00:00
Shirone
12fa9d858d Merge pull request #533 from AutoMaker-Org/feature/v0.12.0rc-1768605477061-fhv5
fix: Codex freezes
2026-01-16 23:59:16 +00:00
Shirone
c4e1a58e0d refactor: update timeout constants in CLI and Codex providers
- Removed redundant definition of CLI base timeout in `cli-provider.ts` and added a detailed comment explaining its purpose.
- Updated `codex-provider.ts` to use the imported `DEFAULT_TIMEOUT_MS` directly instead of an alias.
- Enhanced unit tests to ensure fallback behavior for invalid reasoning effort values in timeout calculations.
2026-01-17 00:52:57 +01:00
Shirone
8661f33c6d feat: implement atomic file writing and recovery utilities
- Introduced atomic write functionality for JSON files to ensure data integrity during writes.
- Added recovery mechanisms to read JSON files with fallback options for corrupted or missing files.
- Enhanced existing services to utilize atomic write and recovery features for improved reliability.
- Updated tests to cover new atomic writing and recovery scenarios, ensuring robust error handling and data consistency.
2026-01-17 00:50:51 +01:00
Shirone
5c24ca2220 feat: implement dynamic timeout calculation for reasoning efforts in CLI and Codex providers
- Added `calculateReasoningTimeout` function to dynamically adjust timeouts based on reasoning effort levels.
- Updated CLI and Codex providers to utilize the new timeout calculation, addressing potential timeouts for high reasoning efforts.
- Enhanced unit tests to validate timeout behavior for various reasoning efforts, ensuring correct timeout values are applied.
2026-01-17 00:50:06 +01:00
webdevcody
14559354dd refactor: update sidebar navigation sections for clarity
- Added Notifications and Project Settings as standalone sections in the sidebar without labels for visual separation.
- Removed the previous 'Other' label to enhance the organization of navigation items.
2026-01-16 18:49:35 -05:00
webdevcody
3bf9dbd43a Merge branch 'v0.12.0rc' of github.com:AutoMaker-Org/automaker into v0.12.0rc 2026-01-16 18:39:31 -05:00
webdevcody
bd3999416b feat: implement notifications and event history features
- Added Notification Service to manage project-level notifications, including creation, listing, marking as read, and dismissing notifications.
- Introduced Event History Service to store and manage historical events, allowing for listing, retrieval, deletion, and replaying of events.
- Integrated notifications into the server and UI, providing real-time updates for feature statuses and operations.
- Enhanced sidebar and project switcher components to display unread notifications count.
- Created dedicated views for managing notifications and event history, improving user experience and accessibility.

These changes enhance the application's ability to inform users about important events and statuses, improving overall usability and responsiveness.
2026-01-16 18:37:11 -05:00
Shirone
cc9f7d48c8 fix: enhance authentication error handling in Claude usage service tests
- Updated test to send a specific authentication error pattern to the data callback.
- Triggered the exit handler to validate the handling of authentication errors.
- Improved error message expectations for better clarity during test failures.
2026-01-16 23:58:48 +01:00
Shirone
6bb0461be7 Merge pull request #527 from AutoMaker-Org/feature/v0.12.0rc-1768598412391-lnp7
feat: implement XML extraction utilities and enhance feature handling
2026-01-16 22:52:55 +00:00
Shirone
16ef026b38 refactor: Centralize UUID generation with fallback support 2026-01-16 23:49:36 +01:00
Shirone
50ed405c4a fix: adress pr comments 2026-01-16 23:41:23 +01:00
Web Dev Cody
5407e1a9ff Merge pull request #525 from stefandevo/feature/project-settings
feat: Separate Project Settings from Global Settings
2026-01-16 17:31:19 -05:00
Stefan de Vogelaere
5436b18f70 refactor: move Project Settings below Tools section in sidebar
- Remove Project Settings from Project section
- Add Project Settings as standalone section below Tools/GitHub
- Use empty label for visual separation without header
- Add horizontal separator line above sections without labels
- Rename to "Project Settings" for clarity
- Keep "Global Settings" at bottom of sidebar
2026-01-16 23:27:53 +01:00
Stefan de Vogelaere
8b7700364d refactor: move project settings to Project section, rename global settings
- Move "Settings" from Tools section to Project section in sidebar
- Rename bottom settings link from "Settings" to "Global Settings"
- Update keyboard shortcut description accordingly
2026-01-16 23:17:50 +01:00
Shirone
3bdf3cbb5c fix: improve branch name generation logic in BoardView and useBoardActions
- Updated the logic for auto-generating branch names to consistently use the primary branch (main/master) and avoid nested feature paths.
- Removed references to currentWorktreeBranch in favor of getPrimaryWorktreeBranch for better clarity and maintainability.
- Enhanced comments to clarify the purpose of branch name generation.
2026-01-16 23:14:22 +01:00
webdevcody
45d9c9a5d8 fix: adjust menu dimensions and formatting in start-automaker.sh
- Increased MENU_BOX_WIDTH and MENU_INNER_WIDTH for better layout.
- Updated printf statements in show_menu() for consistent spacing and alignment of menu options.
- Enhanced exit option formatting for improved readability.
2026-01-16 17:10:20 -05:00
Stefan de Vogelaere
6a23e6ce78 fix: address PR review feedback
- Fix race conditions when rapidly switching projects
  - Added cancellation logic to prevent stale responses from updating state
  - Both project settings and init script loading now properly cancelled on unmount

- Improve error handling in custom icon upload
  - Added toast notifications for validation errors (file type, file size)
  - Added toast notifications for upload success/failure
  - Handle network errors gracefully with user feedback
  - Handle file reader errors
2026-01-16 23:03:21 +01:00
Stefan de Vogelaere
4e53215104 chore: reset package-lock.json to match base branch 2026-01-16 23:03:21 +01:00
Stefan de Vogelaere
2899b6d416 feat: separate project settings from global settings
This PR introduces a new dedicated Project Settings screen accessible from
the sidebar, clearly separating project-specific settings from global
application settings.

- Added new route `/project-settings` with dedicated view
- Sidebar navigation item "Settings" in Tools section (Shift+S shortcut)
- Sidebar-based navigation matching global Settings pattern
- Sections: Identity, Worktrees, Theme, Danger Zone

**Moved to Project Settings:**
- Project name and icon customization
- Project-specific theme override
- Worktree isolation enable/disable (per-project override)
- Init script indicator visibility and auto-dismiss
- Delete branch by default preference
- Initialization script editor
- Delete project (Danger Zone)

**Remains in Global Settings:**
- Global theme (default for all projects)
- Global worktree isolation (default for new projects)
- Feature Defaults, Model Defaults
- API Keys, AI Providers, MCP Servers
- Terminal, Keyboard Shortcuts, Audio
- Account, Security, Developer settings

Both Theme and Worktree Isolation now follow a consistent override pattern:
1. Global Settings defines the default value
2. New projects inherit the global value
3. Project Settings can override for that specific project
4. Changing global setting doesn't affect projects with overrides

- Fixed: Changing global theme was incorrectly overwriting project themes
- Fixed: Project worktree setting not persisting across sessions
- Project settings now properly load from server on component mount

- Shell syntax editor: improved background contrast (bg-background)
- Shell syntax editor: removed distracting active line highlight
- Project Settings header matches Context/Memory views pattern

- `apps/ui/src/routes/project-settings.tsx`
- `apps/ui/src/components/views/project-settings-view/` (9 files)

- Global settings simplified (removed project-specific options)
- Sidebar navigation updated with project settings link
- App store: added project-specific useWorktrees state/actions
- Types: added projectSettings keyboard shortcut
- HTTP client: added missing project settings response fields
2026-01-16 23:03:21 +01:00
Kacper
b263cc615e feat: implement XML extraction utilities and enhance feature handling
- Introduced a new xml-extractor module with functions for XML parsing, including escaping/unescaping XML characters, extracting sections and elements, and managing implemented features.
- Added functionality to add, remove, update, and check for implemented features in the app_spec.txt file.
- Enhanced the create and update feature handlers to check for duplicate titles and trigger synchronization with app_spec.txt on status changes.
- Updated tests to cover new XML extraction utilities and feature handling logic, ensuring robust functionality and reliability.
2026-01-16 22:55:10 +01:00
webdevcody
97b0028919 chore: update package versions to 0.12.0 and 0.12.0rc
- Updated the version in package.json for the main project to 0.12.0rc.
- Updated the version in apps/server/package.json and apps/ui/package.json to 0.12.0.
- Adjusted the version extraction logic in start-automaker.sh to reference the correct package.json path.
2026-01-16 16:48:43 -05:00
webdevcody
fd1727a443 Merge branch 'v0.12.0rc' of github.com:AutoMaker-Org/automaker into v0.12.0rc 2026-01-16 16:11:56 -05:00
webdevcody
597cb9bfae refactor: remove dev.mjs and integrate start-automaker.sh for development mode
- Deleted the dev.mjs script, consolidating development mode functionality into start-automaker.sh.
- Updated package.json to use start-automaker.sh for the "dev" script and added a "start" script for production mode.
- Enhanced start-automaker.sh with production build capabilities and improved argument parsing for better user experience.
- Removed launcher-utils.mjs as its functionality has been integrated into start-automaker.sh.
2026-01-16 16:11:53 -05:00
Kacper
c2430e5bd3 feat: enhance PTY handling for Windows in ClaudeUsageService and TerminalService
- Added detection for Electron environment to improve compatibility with Windows PTY processes.
- Implemented winpty fallback for ConPTY failures, ensuring robust terminal session creation in Electron and other contexts.
- Updated error handling to provide clearer messages for authentication and terminal access issues.
- Refined usage data detection logic to avoid false positives, improving the accuracy of usage reporting.

These changes aim to enhance the reliability and user experience of terminal interactions on Windows, particularly in Electron applications.
2026-01-16 21:53:53 +01:00
Shirone
68df8efd10 Merge pull request #522 from AutoMaker-Org/feature/v0.12.0rc-1768590871767-bl1c
feat: add filters to github issues view
2026-01-16 20:08:05 +00:00
Kacper
c0d64bc994 fix: adress pr comments 2026-01-16 21:05:58 +01:00
Kacper
6237f1a0fe feat: add filtering capabilities to GitHub issues view
- Implemented a comprehensive filtering system for GitHub issues, allowing users to filter by state, labels, assignees, and validation status.
- Introduced a new IssuesFilterControls component for managing filter options.
- Updated the GitHubIssuesView to utilize the new filtering logic, enhancing the user experience by providing clearer visibility into matching issues.
- Added hooks for filtering logic and state management, ensuring efficient updates and rendering of filtered issues.

These changes aim to improve the usability of the issues view by enabling users to easily navigate and manage their issues based on specific criteria.
2026-01-16 20:56:23 +01:00
Web Dev Cody
30c50d9b78 Merge pull request #513 from JZilla808/feature/tui-launcher
feat: add TUI launcher script for easy app startup
2026-01-16 14:51:36 -05:00
Web Dev Cody
03516ac09e Merge pull request #519 from WikiRik/WikiRik/audit-fix
chore: run npm audit fix
2026-01-16 14:43:27 -05:00
Shirone
5e5a136f1f Merge pull request #521 from AutoMaker-Org/feature/v0.12.0rc-1768591325146-pye6
fix: Signals not supported on windows.
2026-01-16 19:39:00 +00:00
Kacper
98c50d44a4 test: mock Unix platform for SIGTERM behavior in ClaudeUsageService tests
Added a mock for the Unix platform in the SIGTERM test case to ensure proper behavior during testing on non-Windows systems. This change enhances the reliability of the tests by simulating the expected environment for process termination.
2026-01-16 20:38:29 +01:00
Kacper
0e9369816f fix: unify PTY process termination handling across platforms
Refactored the process termination logic in both ClaudeUsageService and TerminalService to use a centralized method for killing PTY processes. This ensures consistent handling of process termination across Windows and Unix-like systems, improving reliability and maintainability of the code.
2026-01-16 20:34:12 +01:00
Kacper
be63a59e9c fix: improve process termination handling for Windows
Updated the process termination logic in ClaudeUsageService to handle Windows environments correctly. The code now checks the operating system and calls the appropriate kill method, ensuring consistent behavior across platforms.
2026-01-16 20:27:53 +01:00
Kacper
dbb84aba23 fix: ensure proper type handling for JSON parsing in loadBacklogPlan function
Updated the JSON parsing in the loadBacklogPlan function to explicitly cast the raw input as a string, improving type safety and preventing potential runtime errors when handling backlog plan data.
2026-01-16 20:09:01 +01:00
Shirone
9819d2e91c Merge pull request #514 from Seonfx/fix/510-spec-generation-json-fallback
fix: add JSON fallback for spec generation with custom API endpoints
2026-01-16 19:01:47 +00:00
Kacper
4c24ba5a8b feat: enhance TUI launcher with Docker/Electron process detection
- Add 4 launch options matching dev.mjs (Web, Electron, Docker Dev, Electron+Docker)
- Add arrow key navigation in menu with visual selection indicator
- Add cross-platform port conflict detection and resolution (Windows/Unix)
- Add Docker container detection with Stop/Restart/Attach/Cancel options
- Add Electron process detection when switching between modes
- Add centered, styled output for Docker build progress
- Add HUSKY=0 to docker-compose files to prevent permission errors
- Fix Windows/Git Bash compatibility (platform detection, netstat/taskkill)
- Fix bash arithmetic issue with set -e causing script to hang

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 19:58:32 +01:00
Rik Smale
e67cab1e07 chore: fix lockfile 2026-01-16 19:23:18 +01:00
Rik Smale
132b8f7529 chore: run npm audit fix 2026-01-16 19:18:16 +01:00
Seonfx
d651e9d8d6 fix: address PR review feedback for JSON fallback
- Simplify escapeXml() using 'str == null' check (type narrowing)
- Add validation for extracted JSON before passing to specToXml()
- Prevents runtime errors when JSON doesn't match SpecOutput schema

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-16 13:43:56 -04:00
webdevcody
92f14508aa chore: update environment variable documentation for Anthropic API key
- Changed comments in docker-compose files to clarify that the ANTHROPIC_API_KEY is optional.
- Updated README to reflect changes in authentication setup, emphasizing integration with Claude Code CLI and removing outdated API key instructions.
- Improved clarity on authentication methods and streamlined the setup process for users.
2026-01-16 11:23:45 -05:00
DhanushSantosh
842b059fac fix: remove invalid local keyword in main script body
The 'local' keyword can only be used inside functions. Line 423 had
'local timeout_count=0' in the main script body which caused a bash error.
Removed the unused variable declaration.

Fixes: bash error 'local: can only be used in a function'

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-16 20:44:17 +05:30
DhanushSantosh
49f9ecc168 feat: enhance TUI launcher with production-ready features and documentation
Major improvements to start-automaker.sh launcher script:

**Architecture & Code Quality:**
- Organized into logical sections with clear separators (8 sections)
- Extracted all magic numbers into named constants at top
- Added comprehensive comments throughout

**Functionality:**
- Dynamic version extraction from package.json (no manual updates)
- Pre-flight checks: validates Node.js, npm, tput installed
- Platform detection: warns on Windows/unsupported systems
- Terminal size validation: checks min 70x20, displays warning if too small
- Input timeout: 30-second auto-timeout for hands-free operation
- History tracking: remembers last selected mode in ~/.automaker_launcher_history

**User Experience:**
- Added --help flag with comprehensive usage documentation
- Added --version flag showing version, Node.js, Bash info
- Added --check-deps flag to verify project dependencies
- Added --no-colors flag for terminals without color support
- Added --no-history flag to disable history tracking
- Enhanced cleanup function: restores cursor + echo, better signal handling
- Better error messages with actionable remediation steps
- Improved exit experience: "Goodbye! See you soon." message

**Robustness:**
- Real initialization checks (validates node_modules, build artifacts)
- Spinner uses frame counting instead of infinite loop (max 1.6s)
- Proper signal trap handling (EXIT, INT, TERM)
- Error recovery: respects --no-colors in pre-flight checks

**File Management:**
- Renamed from "start automaker.sh" to "start-automaker.sh" for consistency
- Made script more portable with SCRIPT_DIR detection

**Documentation:**
- Added section to README.md: "Interactive TUI Launcher"
- Documented all launch modes and options with examples
- Added feature list, history file location, usage tips
- Updated table of contents with TUI launcher section

Fixes: #511 (CI test failures resolved)
Improvements: Better UX for new users, production-ready error handling

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-16 20:27:53 +05:30
DhanushSantosh
e02fd889c2 fix: add --force flag to npm install in format-check workflow
Ensures dmg-license can be installed on Linux CI runners even though it's
a darwin-only package. The --force flag allows npm to skip platform mismatches.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-16 19:51:23 +05:30
DhanushSantosh
52a821d3bb fix: add --force flag to npm install in CI to allow platform-specific devDependencies
dmg-license is a darwin-only package required for macOS DMG building. The CI runs on
Linux, so npm install fails when trying to install a platform-specific devDependency.

Using --force allows npm to skip platform mismatches instead of erroring out, allowing
the build to proceed on non-darwin platforms where the darwin-only dependency will simply
be skipped.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-16 19:43:09 +05:30
DhanushSantosh
becd79f1e3 fix: add missing dmg-license dependency to fix release builds
The release workflow was failing for all platforms because macOS DMG
builder requires dmg-license. This single dependency was preventing
AppImage, DEB, RPM, DMG, and EXE artifacts from being built and
uploaded to any release since v0.7.3.

Includes lockfile updates and conversion of git+ssh:// URLs to https://
to prevent SSH key requirement issues in CI/CD and across environments.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-16 19:38:10 +05:30
DhanushSantosh
883ad2a04b fix(backlog-plan): clear running details in generate-plan finally block
Ensure running details are cleared when generation completes or fails, preventing state leaks.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-16 19:37:21 +05:30
DhanushSantosh
bf93cdf0c4 fix(backlog-plan): clear running details when stopping generation
Add setRunningDetails(null) to stop handler to prevent state leaks when aborting operation.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-16 19:37:21 +05:30
DhanushSantosh
c0ea1c736a fix(backlog-plan): clear running details and handle plan cleanup safely
- Add setRunningDetails(null) in finally block of generate handler to prevent state leaks
- Move clearBacklogPlan before response in apply handler and wrap in try-catch to prevent errors after headers sent

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-16 19:37:21 +05:30
DhanushSantosh
8b448b9481 fix: address CodeRabbit security and validation issues in Fedora docs and backlog plan
Documentation improvements:
- Fix GitHub URL placeholder issues in install-fedora.md - GitHub /latest/download/ endpoint
  doesn't support version substitution, use explicit download URL pattern instead
- Improve security in network troubleshooting section:
  - Change ping target from claude.ai (marketing site) to api.anthropic.com (actual API)
  - Remove unsafe 'echo \$ANTHROPIC_API_KEY' command that exposes secrets in shell history
  - Use safe API key check with conditional output instead

Code improvements:
- apps/server/src/routes/backlog-plan/common.ts: Add Array.isArray() validation
  for stored plan shape before returning it. Ensures changes is actually an array,
  not just truthy, preventing downstream runtime errors.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-16 19:37:21 +05:30
DhanushSantosh
12f2b9f2b3 fix: remove invalid license property from RPM configuration
The 'license' property is not supported by electron-builder's RPM schema.
Valid RPM properties are: afterInstall, afterRemove, appArmorProfile,
artifactName, category, compression, depends, description, desktop,
executableArgs, fpm, icon, maintainer, mimeTypes, packageCategory,
packageName, publish, synopsis, vendor.

This fix allows electron-builder to proceed to the RPM build stage.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-16 19:37:21 +05:30
DhanushSantosh
017ff3ca0a fix: resolve TypeScript error in backlog plan loading
Fix type mismatch in loadBacklogPlan where secureFs.readFile with 'utf-8'
encoding returns union type string | Buffer, causing JSON.parse to fail type checking.
Cast raw to string to satisfy TypeScript strict mode.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-16 19:37:21 +05:30
Seonfx
bcec178bbe fix: add JSON fallback for spec generation with custom API endpoints
Fixes spec generation failure when using custom API endpoints (e.g., GLM proxy)
that don't support structured output. The AI returns JSON instead of XML, but
the fallback parser only looked for XML tags.

Changes:
- escapeXml: Handle undefined/null values gracefully (converts to empty string)
- generate-spec: Add JSON extraction fallback when XML tags aren't found
  - Reuses existing extractJson() utility (already used for Cursor models)
  - Converts extracted JSON to XML using specToXml()

Closes #510

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-16 08:37:53 -04:00
Jay Zhou
e3347c7b9c feat: add TUI launcher script for easy app startup
Add a beautiful terminal user interface (TUI) script that provides an
interactive menu for launching Automaker in different modes:

- [1] Web Browser mode (localhost:3007)
- [2] Desktop App (Electron)
- [3] Desktop + Debug (Electron with DevTools)
- [Q] Exit

Features:
- ASCII art logo with gradient colors
- Centered, responsive layout that adapts to terminal size
- Animated spinner during launch sequence
- Cross-shell compatibility (bash/zsh)
- Clean exit handling with cursor restoration

This provides a more user-friendly alternative to remembering
npm commands, especially for new users getting started with
the project.
2026-01-16 03:34:47 -08:00
DhanushSantosh
6529446281 feat: add Fedora/RHEL RPM package support with comprehensive documentation
Add native RPM package building for Fedora-based distributions:
- Extend electron-builder configuration to include RPM target
- Add rpm-build installation to GitHub Actions CI/CD workflow
- Update artifact upload patterns to include .rpm files
- Declare proper RPM dependencies (gtk3, libnotify, nss, etc.)
- Use xz compression for optimal package size

Documentation:
- Update README.md with Fedora/RHEL installation instructions
- Create comprehensive docs/install-fedora.md guide covering:
  - Installation methods (dnf/yum, direct URL)
  - System requirements and capabilities
  - Configuration and troubleshooting
  - SELinux handling and firewall rules
  - Performance tips and security considerations
  - Building from source
- Support for Fedora 39+, RHEL 9+, Rocky Linux, AlmaLinux

End-to-end support enables Fedora users to install Automaker via:
  sudo dnf install ./Automaker-<version>-x86_64.rpm

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-16 12:31:30 +05:30
webdevcody
379551c40e feat: add JSON import/export functionality in settings view
- Introduced a new ImportExportDialog component for managing settings import and export via JSON.
- Integrated JsonSyntaxEditor for editing JSON settings with syntax highlighting.
- Updated SettingsView to include the import/export dialog and associated state management.
- Enhanced SettingsHeader with an import/export button for easy access.

These changes aim to improve user experience by allowing seamless transfer of settings between installations.
2026-01-16 00:34:59 -05:00
webdevcody
7465017600 feat: implement server logging and event hook features
- Introduced server log level configuration and HTTP request logging settings, allowing users to control the verbosity of server logs and enable or disable request logging at runtime.
- Added an Event Hook Service to execute custom actions based on system events, supporting shell commands and HTTP webhooks.
- Enhanced the UI with new sections for managing server logging preferences and event hooks, including a dialog for creating and editing hooks.
- Updated global settings to include server log level and request logging options, ensuring persistence across sessions.

These changes aim to improve debugging capabilities and provide users with customizable event-driven actions within the application.
2026-01-16 00:21:49 -05:00
webdevcody
874c5a36de feat: enable auto loading of ClaudeMd by default
- Updated the default setting for autoLoadClaudeMd from false to true in the global settings. This change aims to enhance user experience by automatically loading ClaudeMd, streamlining the workflow for users.
2026-01-15 23:54:08 -05:00
webdevcody
03436103d1 feat: implement backlog plan management and UI enhancements
- Added functionality to save, clear, and load backlog plans within the application.
- Introduced a new API endpoint for clearing saved backlog plans.
- Enhanced the backlog plan dialog to allow users to review and apply changes to their features.
- Integrated dependency management features in the UI, allowing users to select parent and child dependencies for features.
- Improved the graph view with options to manage plans and visualize dependencies effectively.
- Updated the sidebar and settings to include provider visibility toggles for better user control over model selection.

These changes aim to enhance the user experience by providing robust backlog management capabilities and improving the overall UI for feature planning.
2026-01-15 22:21:46 -05:00
Shirone
cb544e0011 Merge pull request #505 from AutoMaker-Org/feature/v0.12.0rc-1768509532254-tt6z
fix: "Remove Project" button not working on right click of the project
2026-01-15 22:05:24 +00:00
Shirone
df23c9e6ab Merge pull request #507 from AutoMaker-Org/feat/improve-ideation-view
feat: improve ideation view
2026-01-15 22:03:50 +00:00
Shirone
52cc82fb3f feat: enhance ideation dashboard and prompt components
- Added a helper function to map priority levels to badge variants in the IdeationDashboard.
- Improved UI elements in SuggestionCard for better spacing and visual hierarchy.
- Updated PromptCategoryGrid and PromptList components with enhanced hover effects and layout adjustments for a more responsive design.
- Refined button styles and interactions for better user experience across components.

These changes aim to improve the overall usability and aesthetics of the ideation view.
2026-01-15 23:01:12 +01:00
Shirone
d9571bfb8d Merge pull request #506 from AutoMaker-Org/feature/v0.12.0rc-1768509904121-pjft
feat: add discard all functionality to ideation view
2026-01-15 21:39:54 +00:00
Shirone
07d800b589 feat: add discard all functionality to ideation view
- Introduced a new button in the IdeationHeader for discarding all ideas when in dashboard mode.
- Implemented state management for discard readiness and count in IdeationView.
- Added confirmation dialog for discarding ideas in IdeationDashboard.
- Enhanced bulk action readiness checks to include discard operations.

This update improves user experience by allowing bulk discarding of ideas with confirmation, ensuring actions are intentional.
2026-01-15 22:37:26 +01:00
Shirone
ec042de69c fix: streamline context menu behavior for project removal dialog
- Ensure the context menu closes consistently after the confirmation dialog, regardless of user action.
- Reset confirmation state upon dialog closure to prevent unintended interactions.
2026-01-15 22:20:30 +01:00
Shirone
585ae32c32 fix: Prevent race condition in project removal dialog cleanup 2026-01-15 22:15:16 +01:00
Shirone
a89ba04109 fix: project removal not being executed
- Prevent context menu from closing when a confirmation dialog is open.
- Add success toast notification upon project removal.
- Refactor event handlers to account for dialog state, improving user experience.
2026-01-15 22:06:35 +01:00
Shirone
05a3b95d75 Merge pull request #501 from AutoMaker-Org/feature/v0.11.0rc-1768426435282-1ogl
feat: centralize prompts and add customization UI for App Spec, Context, Suggestions, Tasks
2026-01-15 20:20:56 +00:00
Shirone
0e269ca15d fix: update outdated server unit tests
- auto-mode-service-planning.test.ts: Add taskExecutionPrompts argument
  to buildFeaturePrompt calls, update test for implementation instructions
- claude-usage-service.test.ts: Skip deprecated Mac tests (service now
  uses PTY for all platforms), rename Windows tests to PTY tests, update
  to use process.cwd() instead of home directory
- claude-provider.test.ts: Add missing model parameter to environment
  variable passthrough tests

All tests now pass (1093 passed, 23 skipped).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 21:16:46 +01:00
Shirone
fd03cb4afa refactor: split prompt customization into multiple files
Split prompt-customization-section.tsx into focused modules:
- types.ts (51 lines) - Type definitions
- tab-configs.ts (448 lines) - Configuration data for all tabs
- components.tsx (159 lines) - Reusable Banner, PromptField, PromptFieldList
- prompt-customization-section.tsx (176 lines) - Main component

Benefits:
- Main component reduced from ~810 to 176 lines
- Clear separation of concerns
- Easier to find and modify specific parts
- Configuration data isolated for easy updates

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 21:07:38 +01:00
Shirone
d6c5c93fe5 refactor: use data-driven configuration for prompt customization UI
- Replace repetitive JSX with TAB_CONFIGS array defining all tabs and fields
- Create reusable Banner component for info/warning banners
- Create PromptFieldList component for rendering fields from config
- Support nested sections (like Auto Mode's Template Prompts section)
- Reduce file from ~950 lines to ~810 lines (-15% code)

Benefits:
- Adding new prompt tabs/fields is now declarative (just add to config)
- Consistent structure enforced by TypeScript interfaces
- Much easier to maintain and extend

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 21:03:51 +01:00
Shirone
1abf219230 refactor: create reusable PromptTabContent component and add {{count}} placeholder
- Create PromptTabContent reusable component in prompt-customization-section.tsx
- Update all tabs (Agent, Commit Message, Title Generation, Ideation, App Spec,
  Context Description, Suggestions, Task Execution) to use the new component
- Add {{count}} placeholder to DEFAULT_SUGGESTIONS_SYSTEM_PROMPT for dynamic
  suggestion count

Addresses PR review comments from Gemini.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 21:00:32 +01:00
Shirone
3a2ba6dbfe feat: connect Task Execution prompts to auto-mode-service
Update auto-mode-service.ts to use centralized Task Execution prompts
from settings, making all 9 task execution prompts customizable via UI:

- buildFeaturePrompt: uses implementationInstructions and
  playwrightVerificationInstructions from settings
- buildTaskPrompt: uses taskPromptTemplate with variable substitution
- buildPipelineStepPrompt: updated to pass prompts through
- executeFeatureWithContext: uses resumeFeatureTemplate
- resolvePlanApproval recovery: uses continuationAfterApprovalTemplate
- Multi-agent continuation: uses continuationAfterApprovalTemplate
- recordLearningsFromFeature: uses learningExtractionSystemPrompt
  and learningExtractionUserPromptTemplate

All 12 prompt categories are now fully customizable from the UI.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 20:54:26 +01:00
Shirone
8fa8ba0a16 fix: address PR comments and complete prompt centralization
- Fix inline type imports in defaults.ts (move to top-level imports)
- Update ideation-service.ts to use centralized prompts from settings
- Update generate-title.ts to use centralized prompts
- Update validate-issue.ts to use centralized prompts
- Clean up validation-schema.ts (prompts already centralized)
- Minor server index cleanup

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 20:31:19 +01:00
Shirone
285f526e0c feat: centralize prompts and add customization UI for App Spec, Context, Suggestions, Tasks
- Add 4 new prompt type interfaces (AppSpecPrompts, ContextDescriptionPrompts,
  SuggestionsPrompts, TaskExecutionPrompts) with resolved types
- Add default prompts for all new categories to @automaker/prompts/defaults.ts
- Add merge functions for new prompt categories in merge.ts
- Update settings-helpers.ts getPromptCustomization() to return all 12 categories
- Update server routes (generate-spec, generate-features-from-spec, describe-file,
  describe-image, generate-suggestions) to use centralized prompts
- Add 4 new tabs in prompt customization UI (App Spec, Context, Suggestions, Tasks)
- Fix Ideation tab layout using grid-cols-4 for even distribution

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 20:13:14 +01:00
webdevcody
bd68b497ac Merge branch 'v0.12.0rc' of github.com:AutoMaker-Org/automaker into v0.12.0rc 2026-01-15 13:14:19 -05:00
webdevcody
06b047cfcb feat: implement bulk feature verification and enhance selection mode
- Added functionality for bulk verifying features in the BoardView, allowing users to mark multiple features as verified at once.
- Introduced a selection target mechanism to differentiate between 'backlog' and 'waiting_approval' features during selection mode.
- Updated the KanbanCard and SelectionActionBar components to support the new selection target logic, improving user experience for bulk actions.
- Enhanced the UI to provide appropriate actions based on the current selection target, including verification options for waiting approval features.
2026-01-15 13:14:15 -05:00
DhanushSantosh
c585cee12f feat: add dynamic usage status icon and tab-aware updates to usage button
- Add provider icon (Anthropic/OpenAI) that displays based on active tab
- Icon color reflects usage status (green/orange/red)
- Progress bar and stale indicator update dynamically when switching tabs
- Shows Claude metrics when Claude tab is active, Codex metrics when Codex tab is active

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-15 21:05:35 +05:30
DhanushSantosh
241fd0b252 Merge remote-tracking branch 'upstream/v0.12.0rc' into patchcraft 2026-01-15 19:38:37 +05:30
DhanushSantosh
164acc1b4e chore: update package-lock.json 2026-01-15 19:38:18 +05:30
397 changed files with 29797 additions and 7004 deletions

View File

@@ -41,7 +41,8 @@ runs:
# Use npm install instead of npm ci to correctly resolve platform-specific
# optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries)
# Skip scripts to avoid electron-builder install-app-deps which uses too much memory
run: npm install --ignore-scripts
# Use --force to allow platform-specific dev dependencies like dmg-license on non-darwin platforms
run: npm install --ignore-scripts --force
- name: Install Linux native bindings
shell: bash

View File

@@ -25,7 +25,7 @@ jobs:
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm install --ignore-scripts
run: npm install --ignore-scripts --force
- name: Check formatting
run: npm run format:check

View File

@@ -35,6 +35,11 @@ jobs:
with:
check-lockfile: 'true'
- name: Install RPM build tools (Linux)
if: matrix.os == 'ubuntu-latest'
shell: bash
run: sudo apt-get update && sudo apt-get install -y rpm
- name: Build Electron app (macOS)
if: matrix.os == 'macos-latest'
shell: bash
@@ -73,7 +78,7 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: linux-builds
path: apps/ui/release/*.{AppImage,deb}
path: apps/ui/release/*.{AppImage,deb,rpm}
retention-days: 30
upload:
@@ -104,8 +109,8 @@ jobs:
uses: softprops/action-gh-release@v2
with:
files: |
artifacts/macos-builds/*
artifacts/windows-builds/*
artifacts/linux-builds/*
artifacts/macos-builds/*.{dmg,zip,blockmap}
artifacts/windows-builds/*.{exe,blockmap}
artifacts/linux-builds/*.{AppImage,deb,rpm,blockmap}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -166,7 +166,11 @@ Use `resolveModelString()` from `@automaker/model-resolver` to convert model ali
## Environment Variables
- `ANTHROPIC_API_KEY` - Anthropic API key (or use Claude Code CLI auth)
- `HOST` - Host to bind server to (default: 0.0.0.0)
- `HOSTNAME` - Hostname for user-facing URLs (default: localhost)
- `PORT` - Server port (default: 3008)
- `DATA_DIR` - Data storage directory (default: ./data)
- `ALLOWED_ROOT_DIRECTORY` - Restrict file operations to specific directory
- `AUTOMAKER_MOCK_AGENT=true` - Enable mock agent mode for CI testing
- `AUTOMAKER_AUTO_LOGIN=true` - Skip login prompt in development (disabled when NODE_ENV=production)
- `VITE_HOSTNAME` - Hostname for frontend API URLs (default: localhost)

160
README.md
View File

@@ -28,6 +28,7 @@
- [Quick Start](#quick-start)
- [How to Run](#how-to-run)
- [Development Mode](#development-mode)
- [Interactive TUI Launcher](#interactive-tui-launcher-recommended-for-new-users)
- [Building for Production](#building-for-production)
- [Testing](#testing)
- [Linting](#linting)
@@ -101,11 +102,9 @@ In the Discord, you can:
### Prerequisites
- **Node.js 18+** (tested with Node.js 22)
- **Node.js 22+** (required: >=22.0.0 <23.0.0)
- **npm** (comes with Node.js)
- **Authentication** (choose one):
- **[Claude Code CLI](https://code.claude.com/docs/en/overview)** (recommended) - Install and authenticate, credentials used automatically
- **Anthropic API Key** - Direct API key for Claude Agent SDK ([get one here](https://console.anthropic.com/))
- **[Claude Code CLI](https://code.claude.com/docs/en/overview)** - Install and authenticate with your Anthropic subscription. Automaker integrates with your authenticated Claude Code CLI to access Claude models.
### Quick Start
@@ -117,30 +116,14 @@ cd automaker
# 2. Install dependencies
npm install
# 3. Build shared packages (can be skipped - npm run dev does it automatically)
npm run build:packages
# 4. Start Automaker
# 3. Start Automaker
npm run dev
# Choose between:
# 1. Web Application (browser at localhost:3007)
# 2. Desktop Application (Electron - recommended)
```
**Authentication Setup:** On first run, Automaker will automatically show a setup wizard where you can configure authentication. You can choose to:
- Use **Claude Code CLI** (recommended) - Automaker will detect your CLI credentials automatically
- Enter an **API key** directly in the wizard
If you prefer to set up authentication before running (e.g., for headless deployments or CI/CD), you can set it manually:
```bash
# Option A: Environment variable
export ANTHROPIC_API_KEY="sk-ant-..."
# Option B: Create .env file in project root
echo "ANTHROPIC_API_KEY=sk-ant-..." > .env
```
**Authentication:** Automaker integrates with your authenticated Claude Code CLI. Make sure you have [installed and authenticated](https://code.claude.com/docs/en/quickstart) the Claude Code CLI before running Automaker. Your CLI credentials will be detected automatically.
**For Development:** `npm run dev` starts the development server with Vite live reload and hot module replacement for fast refresh and instant updates as you make changes.
@@ -179,6 +162,40 @@ npm run dev:electron:wsl:gpu
npm run dev:web
```
### Interactive TUI Launcher (Recommended for New Users)
For a user-friendly interactive menu, use the built-in TUI launcher script:
```bash
# Show interactive menu with all launch options
./start-automaker.sh
# Or launch directly without menu
./start-automaker.sh web # Web browser
./start-automaker.sh electron # Desktop app
./start-automaker.sh electron-debug # Desktop + DevTools
# Additional options
./start-automaker.sh --help # Show all available options
./start-automaker.sh --version # Show version information
./start-automaker.sh --check-deps # Verify project dependencies
./start-automaker.sh --no-colors # Disable colored output
./start-automaker.sh --no-history # Don't remember last choice
```
**Features:**
- 🎨 Beautiful terminal UI with gradient colors and ASCII art
- Interactive menu (press 1-3 to select, Q to exit)
- 💾 Remembers your last choice
- Pre-flight checks (validates Node.js, npm, dependencies)
- 📏 Responsive layout (adapts to terminal size)
- 30-second timeout for hands-free selection
- 🌐 Cross-shell compatible (bash/zsh)
**History File:**
Your last selected mode is saved in `~/.automaker_launcher_history` for quick re-runs.
### Building for Production
#### Web Application
@@ -197,11 +214,30 @@ npm run build:electron
# Platform-specific builds
npm run build:electron:mac # macOS (DMG + ZIP, x64 + arm64)
npm run build:electron:win # Windows (NSIS installer, x64)
npm run build:electron:linux # Linux (AppImage + DEB, x64)
npm run build:electron:linux # Linux (AppImage + DEB + RPM, x64)
# Output directory: apps/ui/release/
```
**Linux Distribution Packages:**
- **AppImage**: Universal format, works on any Linux distribution
- **DEB**: Ubuntu, Debian, Linux Mint, Pop!\_OS
- **RPM**: Fedora, RHEL, Rocky Linux, AlmaLinux, openSUSE
**Installing on Fedora/RHEL:**
```bash
# Download the RPM package
wget https://github.com/AutoMaker-Org/automaker/releases/latest/download/Automaker-<version>-x86_64.rpm
# Install with dnf (Fedora)
sudo dnf install ./Automaker-<version>-x86_64.rpm
# Or with yum (RHEL/CentOS)
sudo yum localinstall ./Automaker-<version>-x86_64.rpm
```
#### Docker Deployment
Docker provides the most secure way to run Automaker by isolating it from your host filesystem.
@@ -220,16 +256,9 @@ docker-compose logs -f
docker-compose down
```
##### Configuration
##### Authentication
Create a `.env` file in the project root if using API key authentication:
```bash
# Optional: Anthropic API key (not needed if using Claude CLI authentication)
ANTHROPIC_API_KEY=sk-ant-...
```
**Note:** Most users authenticate via Claude CLI instead of API keys. See [Claude CLI Authentication](#claude-cli-authentication-optional) below.
Automaker integrates with your authenticated Claude Code CLI. To use CLI authentication in Docker, mount your Claude CLI config directory (see [Claude CLI Authentication](#claude-cli-authentication) below).
##### Working with Projects (Host Directory Access)
@@ -243,9 +272,9 @@ services:
- /path/to/your/project:/projects/your-project
```
##### Claude CLI Authentication (Optional)
##### Claude CLI Authentication
To use Claude Code CLI authentication instead of an API key, mount your Claude CLI config directory:
Mount your Claude CLI config directory to use your authenticated CLI credentials:
```yaml
services:
@@ -343,10 +372,6 @@ npm run lint
### Environment Configuration
#### Authentication (if not using Claude Code CLI)
- `ANTHROPIC_API_KEY` - Your Anthropic API key for Claude Agent SDK (not needed if using Claude Code CLI)
#### Optional - Server
- `PORT` - Server port (default: 3008)
@@ -357,49 +382,23 @@ npm run lint
- `AUTOMAKER_API_KEY` - Optional API authentication for the server
- `ALLOWED_ROOT_DIRECTORY` - Restrict file operations to specific directory
- `CORS_ORIGIN` - CORS policy (default: \*)
- `CORS_ORIGIN` - CORS allowed origins (comma-separated list; defaults to localhost only)
#### Optional - Development
- `VITE_SKIP_ELECTRON` - Skip Electron in dev mode
- `OPEN_DEVTOOLS` - Auto-open DevTools in Electron
- `AUTOMAKER_SKIP_SANDBOX_WARNING` - Skip sandbox warning dialog (useful for dev/CI)
- `AUTOMAKER_AUTO_LOGIN=true` - Skip login prompt in development (ignored when NODE_ENV=production)
### Authentication Setup
#### Option 1: Claude Code CLI (Recommended)
Automaker integrates with your authenticated Claude Code CLI and uses your Anthropic subscription.
Install and authenticate the Claude Code CLI following the [official quickstart guide](https://code.claude.com/docs/en/quickstart).
Once authenticated, Automaker will automatically detect and use your CLI credentials. No additional configuration needed!
#### Option 2: Direct API Key
If you prefer not to use the CLI, you can provide an Anthropic API key directly using one of these methods:
##### 2a. Shell Configuration
Add to your `~/.bashrc` or `~/.zshrc`:
```bash
export ANTHROPIC_API_KEY="sk-ant-..."
```
Then restart your terminal or run `source ~/.bashrc` (or `source ~/.zshrc`).
##### 2b. .env File
Create a `.env` file in the project root (gitignored):
```bash
ANTHROPIC_API_KEY=sk-ant-...
PORT=3008
DATA_DIR=./data
```
##### 2c. In-App Storage
The application can store your API key securely in the settings UI. The key is persisted in the `DATA_DIR` directory.
## Features
### Core Workflow
@@ -508,20 +507,24 @@ Automaker provides several specialized views accessible via the sidebar or keybo
| **Agent** | `A` | Interactive chat sessions with AI agents for exploratory work and questions |
| **Spec** | `D` | Project specification editor with AI-powered generation and feature suggestions |
| **Context** | `C` | Manage context files (markdown, images) that AI agents automatically reference |
| **Profiles** | `M` | Create and manage AI agent profiles with custom prompts and configurations |
| **Settings** | `S` | Configure themes, shortcuts, defaults, authentication, and more |
| **Terminal** | `T` | Integrated terminal with tabs, splits, and persistent sessions |
| **GitHub Issues** | - | Import and validate GitHub issues, convert to tasks |
| **Graph** | `H` | Visualize feature dependencies with interactive graph visualization |
| **Ideation** | `I` | Brainstorm and generate ideas with AI assistance |
| **Memory** | `Y` | View and manage agent memory and conversation history |
| **GitHub Issues** | `G` | Import and validate GitHub issues, convert to tasks |
| **GitHub PRs** | `R` | View and manage GitHub pull requests |
| **Running Agents** | - | View all active agents across projects with status and progress |
### Keyboard Navigation
All shortcuts are customizable in Settings. Default shortcuts:
- **Navigation:** `K` (Board), `A` (Agent), `D` (Spec), `C` (Context), `S` (Settings), `M` (Profiles), `T` (Terminal)
- **Navigation:** `K` (Board), `A` (Agent), `D` (Spec), `C` (Context), `S` (Settings), `T` (Terminal), `H` (Graph), `I` (Ideation), `Y` (Memory), `G` (GitHub Issues), `R` (GitHub PRs)
- **UI:** `` ` `` (Toggle sidebar)
- **Actions:** `N` (New item in current view), `G` (Start next features), `O` (Open project), `P` (Project picker)
- **Actions:** `N` (New item in current view), `O` (Open project), `P` (Project picker)
- **Projects:** `Q`/`E` (Cycle previous/next project)
- **Terminal:** `Alt+D` (Split right), `Alt+S` (Split down), `Alt+W` (Close), `Alt+T` (New tab)
## Architecture
@@ -586,10 +589,16 @@ Stored in `{projectPath}/.automaker/`:
│ ├── agent-output.md # AI agent output log
│ └── images/ # Attached images
├── context/ # Context files for AI agents
├── worktrees/ # Git worktree metadata
├── validations/ # GitHub issue validation results
├── ideation/ # Brainstorming and analysis data
│ └── analysis.json # Project structure analysis
├── board/ # Board-related data
├── images/ # Project-level images
├── settings.json # Project-specific settings
├── spec.md # Project specification
├── analysis.json # Project structure analysis
└── feature-suggestions.json # AI-generated suggestions
├── app_spec.txt # Project specification (XML format)
├── active-branches.json # Active git branches tracking
└── execution-state.json # Auto-mode execution state
```
#### Global Data
@@ -627,7 +636,6 @@ data/
- [Contributing Guide](./CONTRIBUTING.md) - How to contribute to Automaker
- [Project Documentation](./docs/) - Architecture guides, patterns, and developer docs
- [Docker Isolation Guide](./docs/docker-isolation.md) - Security-focused Docker deployment
- [Shared Packages Guide](./docs/llm-shared-packages.md) - Using monorepo packages
### Community

View File

@@ -44,6 +44,11 @@ CORS_ORIGIN=http://localhost:3007
# OPTIONAL - Server
# ============================================
# Host to bind the server to (default: 0.0.0.0)
# Use 0.0.0.0 to listen on all interfaces (recommended for Docker/remote access)
# Use 127.0.0.1 or localhost to restrict to local connections only
HOST=0.0.0.0
# Port to run the server on
PORT=3008
@@ -63,6 +68,14 @@ TERMINAL_PASSWORD=
ENABLE_REQUEST_LOGGING=false
# ============================================
# OPTIONAL - UI Behavior
# ============================================
# Skip the sandbox warning dialog on startup (default: false)
# Set to "true" to disable the warning entirely (useful for dev/CI environments)
AUTOMAKER_SKIP_SANDBOX_WARNING=false
# ============================================
# OPTIONAL - Debugging
# ============================================

View File

@@ -1,6 +1,6 @@
{
"name": "@automaker/server",
"version": "0.11.0",
"version": "0.12.0",
"description": "Backend server for Automaker - provides API for both web and Electron modes",
"author": "AutoMaker Team",
"license": "SEE LICENSE IN LICENSE",

View File

@@ -17,9 +17,19 @@ import dotenv from 'dotenv';
import { createEventEmitter, type EventEmitter } from './lib/events.js';
import { initAllowedPaths } from '@automaker/platform';
import { createLogger } from '@automaker/utils';
import { createLogger, setLogLevel, LogLevel } from '@automaker/utils';
const logger = createLogger('Server');
/**
* Map server log level string to LogLevel enum
*/
const LOG_LEVEL_MAP: Record<string, LogLevel> = {
error: LogLevel.ERROR,
warn: LogLevel.WARN,
info: LogLevel.INFO,
debug: LogLevel.DEBUG,
};
import { authMiddleware, validateWsConnectionToken, checkRawAuthentication } from './lib/auth.js';
import { requireJsonContentType } from './middleware/require-json-content-type.js';
import { createAuthRoutes } from './routes/auth/index.js';
@@ -68,32 +78,72 @@ import { pipelineService } from './services/pipeline-service.js';
import { createIdeationRoutes } from './routes/ideation/index.js';
import { IdeationService } from './services/ideation-service.js';
import { getDevServerService } from './services/dev-server-service.js';
import { eventHookService } from './services/event-hook-service.js';
import { createNotificationsRoutes } from './routes/notifications/index.js';
import { getNotificationService } from './services/notification-service.js';
import { createEventHistoryRoutes } from './routes/event-history/index.js';
import { getEventHistoryService } from './services/event-history-service.js';
// Load environment variables
dotenv.config();
const PORT = parseInt(process.env.PORT || '3008', 10);
const HOST = process.env.HOST || '0.0.0.0';
const HOSTNAME = process.env.HOSTNAME || 'localhost';
const DATA_DIR = process.env.DATA_DIR || './data';
const ENABLE_REQUEST_LOGGING = process.env.ENABLE_REQUEST_LOGGING !== 'false'; // Default to true
logger.info('[SERVER_STARTUP] process.env.DATA_DIR:', process.env.DATA_DIR);
logger.info('[SERVER_STARTUP] Resolved DATA_DIR:', DATA_DIR);
logger.info('[SERVER_STARTUP] process.cwd():', process.cwd());
const ENABLE_REQUEST_LOGGING_DEFAULT = process.env.ENABLE_REQUEST_LOGGING !== 'false'; // Default to true
// Runtime-configurable request logging flag (can be changed via settings)
let requestLoggingEnabled = ENABLE_REQUEST_LOGGING_DEFAULT;
/**
* Enable or disable HTTP request logging at runtime
*/
export function setRequestLoggingEnabled(enabled: boolean): void {
requestLoggingEnabled = enabled;
}
/**
* Get current request logging state
*/
export function isRequestLoggingEnabled(): boolean {
return requestLoggingEnabled;
}
// Width for log box content (excluding borders)
const BOX_CONTENT_WIDTH = 67;
// Check for required environment variables
const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY;
if (!hasAnthropicKey) {
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 = 'Set your Anthropic API key:'.padEnd(BOX_CONTENT_WIDTH);
const w3 = ' export ANTHROPIC_API_KEY="sk-ant-..."'.padEnd(BOX_CONTENT_WIDTH);
const w4 = 'Or use the setup wizard in Settings to configure authentication.'.padEnd(
BOX_CONTENT_WIDTH
);
logger.warn(`
╔═══════════════════════════════════════════════════════════════════════
⚠️ WARNING: No Claude authentication configured
║ ║
The Claude Agent SDK requires authentication to function.
Set your Anthropic API key:
export ANTHROPIC_API_KEY="sk-ant-..."
Or use the setup wizard in Settings to configure authentication.
╚═══════════════════════════════════════════════════════════════════════╝
╔═════════════════════════════════════════════════════════════════════╗
${wHeader}
╠═════════════════════════════════════════════════════════════════════╣
${w1}
${w2}
${w3}
${w4}
║ ║
╚═════════════════════════════════════════════════════════════════════╝
`);
} else {
logger.info('✓ ANTHROPIC_API_KEY detected (API key auth)');
logger.info('✓ ANTHROPIC_API_KEY detected');
}
// Initialize security
@@ -103,22 +153,21 @@ initAllowedPaths();
const app = express();
// Middleware
// Custom colored logger showing only endpoint and status code (configurable via ENABLE_REQUEST_LOGGING env var)
if (ENABLE_REQUEST_LOGGING) {
morgan.token('status-colored', (_req, res) => {
const status = res.statusCode;
if (status >= 500) return `\x1b[31m${status}\x1b[0m`; // Red for server errors
if (status >= 400) return `\x1b[33m${status}\x1b[0m`; // Yellow for client errors
if (status >= 300) return `\x1b[36m${status}\x1b[0m`; // Cyan for redirects
return `\x1b[32m${status}\x1b[0m`; // Green for success
});
// Custom colored logger showing only endpoint and status code (dynamically configurable)
morgan.token('status-colored', (_req, res) => {
const status = res.statusCode;
if (status >= 500) return `\x1b[31m${status}\x1b[0m`; // Red for server errors
if (status >= 400) return `\x1b[33m${status}\x1b[0m`; // Yellow for client errors
if (status >= 300) return `\x1b[36m${status}\x1b[0m`; // Cyan for redirects
return `\x1b[32m${status}\x1b[0m`; // Green for success
});
app.use(
morgan(':method :url :status-colored', {
skip: (req) => req.url === '/api/health', // Skip health check logs
})
);
}
app.use(
morgan(':method :url :status-colored', {
// Skip when request logging is disabled or for health check endpoints
skip: (req) => !requestLoggingEnabled || req.url === '/api/health',
})
);
// CORS configuration
// When using credentials (cookies), origin cannot be '*'
// We dynamically allow the requesting origin for local development
@@ -142,14 +191,25 @@ app.use(
return;
}
// For local development, allow localhost origins
if (
origin.startsWith('http://localhost:') ||
origin.startsWith('http://127.0.0.1:') ||
origin.startsWith('http://[::1]:')
) {
callback(null, origin);
return;
// For local development, allow all localhost/loopback origins (any port)
try {
const url = new URL(origin);
const hostname = url.hostname;
if (
hostname === 'localhost' ||
hostname === '127.0.0.1' ||
hostname === '::1' ||
hostname === '0.0.0.0' ||
hostname.startsWith('192.168.') ||
hostname.startsWith('10.') ||
hostname.startsWith('172.')
) {
callback(null, origin);
return;
}
} catch (err) {
// Ignore URL parsing errors
}
// Reject other origins by default for security
@@ -181,8 +241,50 @@ const ideationService = new IdeationService(events, settingsService, featureLoad
const devServerService = getDevServerService();
devServerService.setEventEmitter(events);
// Initialize Notification Service with event emitter for real-time updates
const notificationService = getNotificationService();
notificationService.setEventEmitter(events);
// Initialize Event History Service
const eventHistoryService = getEventHistoryService();
// Initialize Event Hook Service for custom event triggers (with history storage)
eventHookService.initialize(events, settingsService, eventHistoryService);
// Initialize services
(async () => {
// Migrate settings from legacy Electron userData location if needed
// This handles users upgrading from versions that stored settings in ~/.config/Automaker (Linux),
// ~/Library/Application Support/Automaker (macOS), or %APPDATA%\Automaker (Windows)
// to the new shared ./data directory
try {
const migrationResult = await settingsService.migrateFromLegacyElectronPath();
if (migrationResult.migrated) {
logger.info(`Settings migrated from legacy location: ${migrationResult.legacyPath}`);
logger.info(`Migrated files: ${migrationResult.migratedFiles.join(', ')}`);
}
if (migrationResult.errors.length > 0) {
logger.warn('Migration errors:', migrationResult.errors);
}
} catch (err) {
logger.warn('Failed to check for legacy settings migration:', err);
}
// Apply logging settings from saved settings
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'}`);
} catch (err) {
logger.warn('Failed to load logging settings, using defaults');
}
await agentService.initialize();
logger.info('Agent service initialized');
@@ -219,7 +321,7 @@ app.get('/api/health/detailed', createDetailedHandler());
app.use('/api/fs', createFsRoutes(events));
app.use('/api/agent', createAgentRoutes(agentService, events));
app.use('/api/sessions', createSessionsRoutes(agentService));
app.use('/api/features', createFeaturesRoutes(featureLoader));
app.use('/api/features', createFeaturesRoutes(featureLoader, settingsService, events));
app.use('/api/auto-mode', createAutoModeRoutes(autoModeService));
app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService));
app.use('/api/worktree', createWorktreeRoutes(events, settingsService));
@@ -240,6 +342,8 @@ app.use('/api/backlog-plan', createBacklogPlanRoutes(events, settingsService));
app.use('/api/mcp', createMCPRoutes(mcpTestService));
app.use('/api/pipeline', createPipelineRoutes(pipelineService));
app.use('/api/ideation', createIdeationRoutes(events, ideationService, featureLoader));
app.use('/api/notifications', createNotificationsRoutes(notificationService));
app.use('/api/event-history', createEventHistoryRoutes(eventHistoryService, settingsService));
// Create HTTP server
const server = createServer(app);
@@ -551,46 +655,81 @@ terminalWss.on('connection', (ws: WebSocket, req: import('http').IncomingMessage
});
// Start server with error handling for port conflicts
const startServer = (port: number) => {
server.listen(port, () => {
const startServer = (port: number, host: string) => {
server.listen(port, host, () => {
const terminalStatus = isTerminalEnabled()
? isTerminalPasswordRequired()
? 'enabled (password protected)'
: 'enabled'
: 'disabled';
const portStr = port.toString().padEnd(4);
// Build URLs for display
const listenAddr = `${host}:${port}`;
const httpUrl = `http://${HOSTNAME}:${port}`;
const wsEventsUrl = `ws://${HOSTNAME}:${port}/api/events`;
const wsTerminalUrl = `ws://${HOSTNAME}:${port}/api/terminal/ws`;
const healthUrl = `http://${HOSTNAME}:${port}/api/health`;
const sHeader = '🚀 Automaker Backend Server'.padEnd(BOX_CONTENT_WIDTH);
const s1 = `Listening: ${listenAddr}`.padEnd(BOX_CONTENT_WIDTH);
const s2 = `HTTP API: ${httpUrl}`.padEnd(BOX_CONTENT_WIDTH);
const s3 = `WebSocket: ${wsEventsUrl}`.padEnd(BOX_CONTENT_WIDTH);
const s4 = `Terminal WS: ${wsTerminalUrl}`.padEnd(BOX_CONTENT_WIDTH);
const s5 = `Health: ${healthUrl}`.padEnd(BOX_CONTENT_WIDTH);
const s6 = `Terminal: ${terminalStatus}`.padEnd(BOX_CONTENT_WIDTH);
logger.info(`
╔═══════════════════════════════════════════════════════╗
Automaker Backend Server
╠═══════════════════════════════════════════════════════╣
HTTP API: http://localhost:${portStr}
WebSocket: ws://localhost:${portStr}/api/events
Terminal: ws://localhost:${portStr}/api/terminal/ws
Health: http://localhost:${portStr}/api/health
Terminal: ${terminalStatus.padEnd(37)}
╚═══════════════════════════════════════════════════════╝
╔═════════════════════════════════════════════════════════════════════
${sHeader}
╠═════════════════════════════════════════════════════════════════════
${s1}
${s2}
${s3}
${s4}
${s5}
${s6}
║ ║
╚═════════════════════════════════════════════════════════════════════╝
`);
});
server.on('error', (error: NodeJS.ErrnoException) => {
if (error.code === 'EADDRINUSE') {
const portStr = port.toString();
const nextPortStr = (port + 1).toString();
const killCmd = `lsof -ti:${portStr} | xargs kill -9`;
const altCmd = `PORT=${nextPortStr} npm run dev:server`;
const eHeader = `❌ ERROR: Port ${portStr} is already in use`.padEnd(BOX_CONTENT_WIDTH);
const e1 = 'Another process is using this port.'.padEnd(BOX_CONTENT_WIDTH);
const e2 = 'To fix this, try one of:'.padEnd(BOX_CONTENT_WIDTH);
const e3 = '1. Kill the process using the port:'.padEnd(BOX_CONTENT_WIDTH);
const e4 = ` ${killCmd}`.padEnd(BOX_CONTENT_WIDTH);
const e5 = '2. Use a different port:'.padEnd(BOX_CONTENT_WIDTH);
const e6 = ` ${altCmd}`.padEnd(BOX_CONTENT_WIDTH);
const e7 = '3. Use the init.sh script which handles this:'.padEnd(BOX_CONTENT_WIDTH);
const e8 = ' ./init.sh'.padEnd(BOX_CONTENT_WIDTH);
logger.error(`
╔═══════════════════════════════════════════════════════╗
❌ ERROR: Port ${port} is already in use
╠═══════════════════════════════════════════════════════╣
Another process is using this port.
To fix this, try one of:
1. Kill the process using the port:
lsof -ti:${port} | xargs kill -9
2. Use a different port:
PORT=${port + 1} npm run dev:server
3. Use the init.sh script which handles this:
./init.sh
╚═══════════════════════════════════════════════════════╝
╔═════════════════════════════════════════════════════════════════════
${eHeader}
╠═════════════════════════════════════════════════════════════════════
${e1}
${e2}
${e3}
${e4}
${e5}
${e6}
${e7}
${e8}
║ ║
╚═════════════════════════════════════════════════════════════════════╝
`);
process.exit(1);
} else {
@@ -600,7 +739,7 @@ const startServer = (port: number) => {
});
};
startServer(PORT);
startServer(PORT, HOST);
// Global error handlers to prevent crashes from uncaught errors
process.on('unhandledRejection', (reason: unknown, _promise: Promise<unknown>) => {

View File

@@ -11,8 +11,12 @@ export { specOutputSchema } from '@automaker/types';
/**
* Escape special XML characters
* Handles undefined/null values by converting them to empty strings
*/
function escapeXml(str: string): string {
export function escapeXml(str: string | undefined | null): string {
if (str == null) {
return '';
}
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')

View File

@@ -130,19 +130,47 @@ function ensureApiKey(): string {
// API key - always generated/loaded on startup for CSRF protection
const API_KEY = ensureApiKey();
// Width for log box content (excluding borders)
const BOX_CONTENT_WIDTH = 67;
// Print API key to console for web mode users (unless suppressed for production logging)
if (process.env.AUTOMAKER_HIDE_API_KEY !== 'true') {
const autoLoginEnabled = process.env.AUTOMAKER_AUTO_LOGIN === 'true';
const autoLoginStatus = autoLoginEnabled ? 'enabled (auto-login active)' : 'disabled';
// Build box lines with exact padding
const header = '🔐 API Key for Web Mode Authentication'.padEnd(BOX_CONTENT_WIDTH);
const line1 = "When accessing via browser, you'll be prompted to enter this key:".padEnd(
BOX_CONTENT_WIDTH
);
const line2 = API_KEY.padEnd(BOX_CONTENT_WIDTH);
const line3 = 'In Electron mode, authentication is handled automatically.'.padEnd(
BOX_CONTENT_WIDTH
);
const line4 = `Auto-login (AUTOMAKER_AUTO_LOGIN): ${autoLoginStatus}`.padEnd(BOX_CONTENT_WIDTH);
const tipHeader = '💡 Tips'.padEnd(BOX_CONTENT_WIDTH);
const line5 = 'Set AUTOMAKER_API_KEY env var to use a fixed key'.padEnd(BOX_CONTENT_WIDTH);
const line6 = 'Set AUTOMAKER_AUTO_LOGIN=true to skip the login prompt'.padEnd(BOX_CONTENT_WIDTH);
logger.info(`
╔═══════════════════════════════════════════════════════════════════════
🔐 API Key for Web Mode Authentication
╠═══════════════════════════════════════════════════════════════════════
When accessing via browser, you'll be prompted to enter this key:
${API_KEY}
In Electron mode, authentication is handled automatically.
╚═══════════════════════════════════════════════════════════════════════╝
╔═════════════════════════════════════════════════════════════════════╗
${header}
╠═════════════════════════════════════════════════════════════════════╣
║ ║
${line1}
║ ║
${line2}
║ ║
${line3}
║ ║
${line4}
║ ║
╠═════════════════════════════════════════════════════════════════════╣
${tipHeader}
╠═════════════════════════════════════════════════════════════════════╣
${line5}
${line6}
╚═════════════════════════════════════════════════════════════════════╝
`);
} else {
logger.info('API key banner hidden (AUTOMAKER_HIDE_API_KEY=true)');
@@ -318,6 +346,15 @@ function checkAuthentication(
return { authenticated: false, errorType: 'invalid_api_key' };
}
// Check for session token in query parameter (web mode - needed for image loads)
const queryToken = query.token;
if (queryToken) {
if (validateSession(queryToken)) {
return { authenticated: true };
}
return { authenticated: false, errorType: 'invalid_session' };
}
// Check for session cookie (web mode)
const sessionToken = cookies[SESSION_COOKIE_NAME];
if (sessionToken && validateSession(sessionToken)) {
@@ -333,8 +370,9 @@ function checkAuthentication(
* Accepts either:
* 1. X-API-Key header (for Electron mode)
* 2. X-Session-Token header (for web mode with explicit token)
* 3. apiKey query parameter (fallback for cases where headers can't be set)
* 4. Session cookie (for web mode)
* 3. apiKey query parameter (fallback for Electron, cases where headers can't be set)
* 4. token query parameter (fallback for web mode, needed for image loads via CSS/img tags)
* 5. Session cookie (for web mode)
*/
export function authMiddleware(req: Request, res: Response, next: NextFunction): void {
const result = checkAuthentication(

View File

@@ -5,12 +5,25 @@
import type { SettingsService } from '../services/settings-service.js';
import type { ContextFilesResult, ContextFileInfo } from '@automaker/utils';
import { createLogger } from '@automaker/utils';
import type { MCPServerConfig, McpServerConfig, PromptCustomization } from '@automaker/types';
import type {
MCPServerConfig,
McpServerConfig,
PromptCustomization,
ClaudeApiProfile,
} from '@automaker/types';
import {
mergeAutoModePrompts,
mergeAgentPrompts,
mergeBacklogPlanPrompts,
mergeEnhancementPrompts,
mergeCommitMessagePrompts,
mergeTitleGenerationPrompts,
mergeIssueValidationPrompts,
mergeIdeationPrompts,
mergeAppSpecPrompts,
mergeContextDescriptionPrompts,
mergeSuggestionsPrompts,
mergeTaskExecutionPrompts,
} from '@automaker/prompts';
const logger = createLogger('SettingsHelper');
@@ -218,6 +231,14 @@ export async function getPromptCustomization(
agent: ReturnType<typeof mergeAgentPrompts>;
backlogPlan: ReturnType<typeof mergeBacklogPlanPrompts>;
enhancement: ReturnType<typeof mergeEnhancementPrompts>;
commitMessage: ReturnType<typeof mergeCommitMessagePrompts>;
titleGeneration: ReturnType<typeof mergeTitleGenerationPrompts>;
issueValidation: ReturnType<typeof mergeIssueValidationPrompts>;
ideation: ReturnType<typeof mergeIdeationPrompts>;
appSpec: ReturnType<typeof mergeAppSpecPrompts>;
contextDescription: ReturnType<typeof mergeContextDescriptionPrompts>;
suggestions: ReturnType<typeof mergeSuggestionsPrompts>;
taskExecution: ReturnType<typeof mergeTaskExecutionPrompts>;
}> {
let customization: PromptCustomization = {};
@@ -239,6 +260,14 @@ export async function getPromptCustomization(
agent: mergeAgentPrompts(customization.agent),
backlogPlan: mergeBacklogPlanPrompts(customization.backlogPlan),
enhancement: mergeEnhancementPrompts(customization.enhancement),
commitMessage: mergeCommitMessagePrompts(customization.commitMessage),
titleGeneration: mergeTitleGenerationPrompts(customization.titleGeneration),
issueValidation: mergeIssueValidationPrompts(customization.issueValidation),
ideation: mergeIdeationPrompts(customization.ideation),
appSpec: mergeAppSpecPrompts(customization.appSpec),
contextDescription: mergeContextDescriptionPrompts(customization.contextDescription),
suggestions: mergeSuggestionsPrompts(customization.suggestions),
taskExecution: mergeTaskExecutionPrompts(customization.taskExecution),
};
}
@@ -321,3 +350,56 @@ export async function getCustomSubagents(
return Object.keys(merged).length > 0 ? merged : undefined;
}
/** Result from getActiveClaudeApiProfile */
export interface ActiveClaudeApiProfileResult {
/** The active profile, or undefined if using direct Anthropic API */
profile: ClaudeApiProfile | undefined;
/** Credentials for resolving 'credentials' apiKeySource */
credentials: import('@automaker/types').Credentials | undefined;
}
/**
* Get the active Claude API profile and credentials from global settings.
* Returns both the profile and credentials for resolving 'credentials' apiKeySource.
*
* @param settingsService - Optional settings service instance
* @param logPrefix - Prefix for log messages (e.g., '[AgentService]')
* @returns Promise resolving to object with profile and credentials
*/
export async function getActiveClaudeApiProfile(
settingsService?: SettingsService | null,
logPrefix = '[SettingsHelper]'
): Promise<ActiveClaudeApiProfileResult> {
if (!settingsService) {
return { profile: undefined, credentials: undefined };
}
try {
const globalSettings = await settingsService.getGlobalSettings();
const credentials = await settingsService.getCredentials();
const profiles = globalSettings.claudeApiProfiles || [];
const activeProfileId = globalSettings.activeClaudeApiProfileId;
// No active profile selected - use direct Anthropic API
if (!activeProfileId) {
return { profile: undefined, credentials };
}
// Find the active profile by ID
const activeProfile = profiles.find((p) => p.id === activeProfileId);
if (activeProfile) {
logger.info(`${logPrefix} Using Claude API profile: ${activeProfile.name}`);
return { profile: activeProfile, credentials };
} else {
logger.warn(
`${logPrefix} Active profile ID "${activeProfileId}" not found, falling back to direct Anthropic API`
);
return { profile: undefined, credentials };
}
} catch (error) {
logger.error(`${logPrefix} Failed to load Claude API profile:`, error);
return { profile: undefined, credentials: undefined };
}
}

View File

@@ -5,18 +5,14 @@
import * as secureFs from './secure-fs.js';
import * as path from 'path';
import type { PRState, WorktreePRInfo } from '@automaker/types';
// Re-export types for backwards compatibility
export type { PRState, WorktreePRInfo };
/** Maximum length for sanitized branch names in filesystem paths */
const MAX_SANITIZED_BRANCH_PATH_LENGTH = 200;
export interface WorktreePRInfo {
number: number;
url: string;
title: string;
state: string;
createdAt: string;
}
export interface WorktreeMetadata {
branch: string;
createdAt: string;

View File

@@ -0,0 +1,611 @@
/**
* XML Extraction Utilities
*
* Robust XML parsing utilities for extracting and updating sections
* from app_spec.txt XML content. Uses regex-based parsing which is
* sufficient for our controlled XML structure.
*
* Note: If more complex XML parsing is needed in the future, consider
* using a library like 'fast-xml-parser' or 'xml2js'.
*/
import { createLogger } from '@automaker/utils';
import type { SpecOutput } from '@automaker/types';
const logger = createLogger('XmlExtractor');
/**
* Represents an implemented feature extracted from XML
*/
export interface ImplementedFeature {
name: string;
description: string;
file_locations?: string[];
}
/**
* Logger interface for optional custom logging
*/
export interface XmlExtractorLogger {
debug: (message: string, ...args: unknown[]) => void;
warn?: (message: string, ...args: unknown[]) => void;
}
/**
* Options for XML extraction operations
*/
export interface ExtractXmlOptions {
/** Custom logger (defaults to internal logger) */
logger?: XmlExtractorLogger;
}
/**
* Escape special XML characters
* Handles undefined/null values by converting them to empty strings
*/
export function escapeXml(str: string | undefined | null): string {
if (str == null) {
return '';
}
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
/**
* Unescape XML entities back to regular characters
*/
export function unescapeXml(str: string): string {
return str
.replace(/&apos;/g, "'")
.replace(/&quot;/g, '"')
.replace(/&gt;/g, '>')
.replace(/&lt;/g, '<')
.replace(/&amp;/g, '&');
}
/**
* Extract the content of a specific XML section
*
* @param xmlContent - The full XML content
* @param tagName - The tag name to extract (e.g., 'implemented_features')
* @param options - Optional extraction options
* @returns The content between the tags, or null if not found
*/
export function extractXmlSection(
xmlContent: string,
tagName: string,
options: ExtractXmlOptions = {}
): string | null {
const log = options.logger || logger;
const regex = new RegExp(`<${tagName}>([\\s\\S]*?)<\\/${tagName}>`, 'i');
const match = xmlContent.match(regex);
if (match) {
log.debug(`Extracted <${tagName}> section`);
return match[1];
}
log.debug(`Section <${tagName}> not found`);
return null;
}
/**
* Extract all values from repeated XML elements
*
* @param xmlContent - The XML content to search
* @param tagName - The tag name to extract values from
* @param options - Optional extraction options
* @returns Array of extracted values (unescaped)
*/
export function extractXmlElements(
xmlContent: string,
tagName: string,
options: ExtractXmlOptions = {}
): string[] {
const log = options.logger || logger;
const values: string[] = [];
const regex = new RegExp(`<${tagName}>([\\s\\S]*?)<\\/${tagName}>`, 'g');
const matches = xmlContent.matchAll(regex);
for (const match of matches) {
values.push(unescapeXml(match[1].trim()));
}
log.debug(`Extracted ${values.length} <${tagName}> elements`);
return values;
}
/**
* Extract implemented features from app_spec.txt XML content
*
* @param specContent - The full XML content of app_spec.txt
* @param options - Optional extraction options
* @returns Array of implemented features with name, description, and optional file_locations
*/
export function extractImplementedFeatures(
specContent: string,
options: ExtractXmlOptions = {}
): ImplementedFeature[] {
const log = options.logger || logger;
const features: ImplementedFeature[] = [];
// Match <implemented_features>...</implemented_features> section
const implementedSection = extractXmlSection(specContent, 'implemented_features', options);
if (!implementedSection) {
log.debug('No implemented_features section found');
return features;
}
// Extract individual feature blocks
const featureRegex = /<feature>([\s\S]*?)<\/feature>/g;
const featureMatches = implementedSection.matchAll(featureRegex);
for (const featureMatch of featureMatches) {
const featureContent = featureMatch[1];
// Extract name
const nameMatch = featureContent.match(/<name>([\s\S]*?)<\/name>/);
const name = nameMatch ? unescapeXml(nameMatch[1].trim()) : '';
// Extract description
const descMatch = featureContent.match(/<description>([\s\S]*?)<\/description>/);
const description = descMatch ? unescapeXml(descMatch[1].trim()) : '';
// Extract file_locations if present
const locationsSection = extractXmlSection(featureContent, 'file_locations', options);
const file_locations = locationsSection
? extractXmlElements(locationsSection, 'location', options)
: undefined;
if (name) {
features.push({
name,
description,
...(file_locations && file_locations.length > 0 ? { file_locations } : {}),
});
}
}
log.debug(`Extracted ${features.length} implemented features`);
return features;
}
/**
* Extract only the feature names from implemented_features section
*
* @param specContent - The full XML content of app_spec.txt
* @param options - Optional extraction options
* @returns Array of feature names
*/
export function extractImplementedFeatureNames(
specContent: string,
options: ExtractXmlOptions = {}
): string[] {
const features = extractImplementedFeatures(specContent, options);
return features.map((f) => f.name);
}
/**
* Generate XML for a single implemented feature
*
* @param feature - The feature to convert to XML
* @param indent - The base indentation level (default: 2 spaces)
* @returns XML string for the feature
*/
export function featureToXml(feature: ImplementedFeature, indent: string = ' '): string {
const i2 = indent.repeat(2);
const i3 = indent.repeat(3);
const i4 = indent.repeat(4);
let xml = `${i2}<feature>
${i3}<name>${escapeXml(feature.name)}</name>
${i3}<description>${escapeXml(feature.description)}</description>`;
if (feature.file_locations && feature.file_locations.length > 0) {
xml += `
${i3}<file_locations>
${feature.file_locations.map((loc) => `${i4}<location>${escapeXml(loc)}</location>`).join('\n')}
${i3}</file_locations>`;
}
xml += `
${i2}</feature>`;
return xml;
}
/**
* Generate XML for an array of implemented features
*
* @param features - Array of features to convert to XML
* @param indent - The base indentation level (default: 2 spaces)
* @returns XML string for the implemented_features section content
*/
export function featuresToXml(features: ImplementedFeature[], indent: string = ' '): string {
return features.map((f) => featureToXml(f, indent)).join('\n');
}
/**
* Update the implemented_features section in XML content
*
* @param specContent - The full XML content
* @param newFeatures - The new features to set
* @param options - Optional extraction options
* @returns Updated XML content with the new implemented_features section
*/
export function updateImplementedFeaturesSection(
specContent: string,
newFeatures: ImplementedFeature[],
options: ExtractXmlOptions = {}
): string {
const log = options.logger || logger;
const indent = ' ';
// Generate new section content
const newSectionContent = featuresToXml(newFeatures, indent);
// Build the new section
const newSection = `<implemented_features>
${newSectionContent}
${indent}</implemented_features>`;
// Check if section exists
const sectionRegex = /<implemented_features>[\s\S]*?<\/implemented_features>/;
if (sectionRegex.test(specContent)) {
log.debug('Replacing existing implemented_features section');
return specContent.replace(sectionRegex, newSection);
}
// If section doesn't exist, try to insert after core_capabilities
const coreCapabilitiesEnd = '</core_capabilities>';
const insertIndex = specContent.indexOf(coreCapabilitiesEnd);
if (insertIndex !== -1) {
const insertPosition = insertIndex + coreCapabilitiesEnd.length;
log.debug('Inserting implemented_features after core_capabilities');
return (
specContent.slice(0, insertPosition) +
'\n\n' +
indent +
newSection +
specContent.slice(insertPosition)
);
}
// As a fallback, insert before </project_specification>
const projectSpecEnd = '</project_specification>';
const fallbackIndex = specContent.indexOf(projectSpecEnd);
if (fallbackIndex !== -1) {
log.debug('Inserting implemented_features before </project_specification>');
return (
specContent.slice(0, fallbackIndex) +
indent +
newSection +
'\n' +
specContent.slice(fallbackIndex)
);
}
log.warn?.('Could not find appropriate insertion point for implemented_features');
log.debug('Could not find appropriate insertion point for implemented_features');
return specContent;
}
/**
* Add a new feature to the implemented_features section
*
* @param specContent - The full XML content
* @param newFeature - The feature to add
* @param options - Optional extraction options
* @returns Updated XML content with the new feature added
*/
export function addImplementedFeature(
specContent: string,
newFeature: ImplementedFeature,
options: ExtractXmlOptions = {}
): string {
const log = options.logger || logger;
// Extract existing features
const existingFeatures = extractImplementedFeatures(specContent, options);
// Check for duplicates by name
const isDuplicate = existingFeatures.some(
(f) => f.name.toLowerCase() === newFeature.name.toLowerCase()
);
if (isDuplicate) {
log.debug(`Feature "${newFeature.name}" already exists, skipping`);
return specContent;
}
// Add the new feature
const updatedFeatures = [...existingFeatures, newFeature];
log.debug(`Adding feature "${newFeature.name}"`);
return updateImplementedFeaturesSection(specContent, updatedFeatures, options);
}
/**
* Remove a feature from the implemented_features section by name
*
* @param specContent - The full XML content
* @param featureName - The name of the feature to remove
* @param options - Optional extraction options
* @returns Updated XML content with the feature removed
*/
export function removeImplementedFeature(
specContent: string,
featureName: string,
options: ExtractXmlOptions = {}
): string {
const log = options.logger || logger;
// Extract existing features
const existingFeatures = extractImplementedFeatures(specContent, options);
// Filter out the feature to remove
const updatedFeatures = existingFeatures.filter(
(f) => f.name.toLowerCase() !== featureName.toLowerCase()
);
if (updatedFeatures.length === existingFeatures.length) {
log.debug(`Feature "${featureName}" not found, no changes made`);
return specContent;
}
log.debug(`Removing feature "${featureName}"`);
return updateImplementedFeaturesSection(specContent, updatedFeatures, options);
}
/**
* Update an existing feature in the implemented_features section
*
* @param specContent - The full XML content
* @param featureName - The name of the feature to update
* @param updates - Partial updates to apply to the feature
* @param options - Optional extraction options
* @returns Updated XML content with the feature modified
*/
export function updateImplementedFeature(
specContent: string,
featureName: string,
updates: Partial<ImplementedFeature>,
options: ExtractXmlOptions = {}
): string {
const log = options.logger || logger;
// Extract existing features
const existingFeatures = extractImplementedFeatures(specContent, options);
// Find and update the feature
let found = false;
const updatedFeatures = existingFeatures.map((f) => {
if (f.name.toLowerCase() === featureName.toLowerCase()) {
found = true;
return {
...f,
...updates,
// Preserve the original name if not explicitly updated
name: updates.name ?? f.name,
};
}
return f;
});
if (!found) {
log.debug(`Feature "${featureName}" not found, no changes made`);
return specContent;
}
log.debug(`Updating feature "${featureName}"`);
return updateImplementedFeaturesSection(specContent, updatedFeatures, options);
}
/**
* Check if a feature exists in the implemented_features section
*
* @param specContent - The full XML content
* @param featureName - The name of the feature to check
* @param options - Optional extraction options
* @returns True if the feature exists
*/
export function hasImplementedFeature(
specContent: string,
featureName: string,
options: ExtractXmlOptions = {}
): boolean {
const features = extractImplementedFeatures(specContent, options);
return features.some((f) => f.name.toLowerCase() === featureName.toLowerCase());
}
/**
* Convert extracted features to SpecOutput.implemented_features format
*
* @param features - Array of extracted features
* @returns Features in SpecOutput format
*/
export function toSpecOutputFeatures(
features: ImplementedFeature[]
): SpecOutput['implemented_features'] {
return features.map((f) => ({
name: f.name,
description: f.description,
...(f.file_locations && f.file_locations.length > 0
? { file_locations: f.file_locations }
: {}),
}));
}
/**
* Convert SpecOutput.implemented_features to ImplementedFeature format
*
* @param specFeatures - Features from SpecOutput
* @returns Features in ImplementedFeature format
*/
export function fromSpecOutputFeatures(
specFeatures: SpecOutput['implemented_features']
): ImplementedFeature[] {
return specFeatures.map((f) => ({
name: f.name,
description: f.description,
...(f.file_locations && f.file_locations.length > 0
? { file_locations: f.file_locations }
: {}),
}));
}
/**
* Represents a roadmap phase extracted from XML
*/
export interface RoadmapPhase {
name: string;
status: string;
description?: string;
}
/**
* Extract the technology stack from app_spec.txt XML content
*
* @param specContent - The full XML content
* @param options - Optional extraction options
* @returns Array of technology names
*/
export function extractTechnologyStack(
specContent: string,
options: ExtractXmlOptions = {}
): string[] {
const log = options.logger || logger;
const techSection = extractXmlSection(specContent, 'technology_stack', options);
if (!techSection) {
log.debug('No technology_stack section found');
return [];
}
const technologies = extractXmlElements(techSection, 'technology', options);
log.debug(`Extracted ${technologies.length} technologies`);
return technologies;
}
/**
* Update the technology_stack section in XML content
*
* @param specContent - The full XML content
* @param technologies - The new technology list
* @param options - Optional extraction options
* @returns Updated XML content
*/
export function updateTechnologyStack(
specContent: string,
technologies: string[],
options: ExtractXmlOptions = {}
): string {
const log = options.logger || logger;
const indent = ' ';
const i2 = indent.repeat(2);
// Generate new section content
const techXml = technologies
.map((t) => `${i2}<technology>${escapeXml(t)}</technology>`)
.join('\n');
const newSection = `<technology_stack>\n${techXml}\n${indent}</technology_stack>`;
// Check if section exists
const sectionRegex = /<technology_stack>[\s\S]*?<\/technology_stack>/;
if (sectionRegex.test(specContent)) {
log.debug('Replacing existing technology_stack section');
return specContent.replace(sectionRegex, newSection);
}
log.debug('No technology_stack section found to update');
return specContent;
}
/**
* Extract roadmap phases from app_spec.txt XML content
*
* @param specContent - The full XML content
* @param options - Optional extraction options
* @returns Array of roadmap phases
*/
export function extractRoadmapPhases(
specContent: string,
options: ExtractXmlOptions = {}
): RoadmapPhase[] {
const log = options.logger || logger;
const phases: RoadmapPhase[] = [];
const roadmapSection = extractXmlSection(specContent, 'implementation_roadmap', options);
if (!roadmapSection) {
log.debug('No implementation_roadmap section found');
return phases;
}
// Extract individual phase blocks
const phaseRegex = /<phase>([\s\S]*?)<\/phase>/g;
const phaseMatches = roadmapSection.matchAll(phaseRegex);
for (const phaseMatch of phaseMatches) {
const phaseContent = phaseMatch[1];
const nameMatch = phaseContent.match(/<name>([\s\S]*?)<\/name>/);
const name = nameMatch ? unescapeXml(nameMatch[1].trim()) : '';
const statusMatch = phaseContent.match(/<status>([\s\S]*?)<\/status>/);
const status = statusMatch ? unescapeXml(statusMatch[1].trim()) : 'pending';
const descMatch = phaseContent.match(/<description>([\s\S]*?)<\/description>/);
const description = descMatch ? unescapeXml(descMatch[1].trim()) : undefined;
if (name) {
phases.push({ name, status, description });
}
}
log.debug(`Extracted ${phases.length} roadmap phases`);
return phases;
}
/**
* Update a roadmap phase status in XML content
*
* @param specContent - The full XML content
* @param phaseName - The name of the phase to update
* @param newStatus - The new status value
* @param options - Optional extraction options
* @returns Updated XML content
*/
export function updateRoadmapPhaseStatus(
specContent: string,
phaseName: string,
newStatus: string,
options: ExtractXmlOptions = {}
): string {
const log = options.logger || logger;
// Find the phase and update its status
// Match the phase block containing the specific name
const phaseRegex = new RegExp(
`(<phase>\\s*<name>\\s*${escapeXml(phaseName)}\\s*<\\/name>\\s*<status>)[\\s\\S]*?(<\\/status>)`,
'i'
);
if (phaseRegex.test(specContent)) {
log.debug(`Updating phase "${phaseName}" status to "${newStatus}"`);
return specContent.replace(phaseRegex, `$1${escapeXml(newStatus)}$2`);
}
log.debug(`Phase "${phaseName}" not found`);
return specContent;
}

View File

@@ -10,7 +10,12 @@ import { BaseProvider } from './base-provider.js';
import { classifyError, getUserFriendlyErrorMessage, createLogger } from '@automaker/utils';
const logger = createLogger('ClaudeProvider');
import { getThinkingTokenBudget, validateBareModelId } from '@automaker/types';
import {
getThinkingTokenBudget,
validateBareModelId,
type ClaudeApiProfile,
type Credentials,
} from '@automaker/types';
import type {
ExecuteOptions,
ProviderMessage,
@@ -21,9 +26,19 @@ import type {
// Explicit allowlist of environment variables to pass to the SDK.
// Only these vars are passed - nothing else from process.env leaks through.
const ALLOWED_ENV_VARS = [
// Authentication
'ANTHROPIC_API_KEY',
'ANTHROPIC_BASE_URL',
'ANTHROPIC_AUTH_TOKEN',
// Endpoint configuration
'ANTHROPIC_BASE_URL',
'API_TIMEOUT_MS',
// Model mappings
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
'ANTHROPIC_DEFAULT_SONNET_MODEL',
'ANTHROPIC_DEFAULT_OPUS_MODEL',
// Traffic control
'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC',
// System vars (always from process.env)
'PATH',
'HOME',
'SHELL',
@@ -33,16 +48,108 @@ const ALLOWED_ENV_VARS = [
'LC_ALL',
];
// System vars are always passed from process.env regardless of profile
const SYSTEM_ENV_VARS = ['PATH', 'HOME', 'SHELL', 'TERM', 'USER', 'LANG', 'LC_ALL'];
/**
* Build environment for the SDK with only explicitly allowed variables
* Build environment for the SDK with only explicitly allowed variables.
* When a profile is provided, uses profile configuration (clean switch - don't inherit from process.env).
* When no profile is provided, uses direct Anthropic API settings from process.env.
*
* @param profile - Optional Claude API profile for alternative endpoint configuration
* @param credentials - Optional credentials object for resolving 'credentials' apiKeySource
*/
function buildEnv(): Record<string, string | undefined> {
function buildEnv(
profile?: ClaudeApiProfile,
credentials?: Credentials
): Record<string, string | undefined> {
const env: Record<string, string | undefined> = {};
for (const key of ALLOWED_ENV_VARS) {
if (profile) {
// Use profile configuration (clean switch - don't inherit non-system vars from process.env)
logger.debug('Building environment from Claude API profile:', {
name: profile.name,
apiKeySource: profile.apiKeySource ?? 'inline',
});
// Resolve API key based on source strategy
let apiKey: string | undefined;
const source = profile.apiKeySource ?? 'inline'; // Default to inline for backwards compat
switch (source) {
case 'inline':
apiKey = profile.apiKey;
break;
case 'env':
apiKey = process.env.ANTHROPIC_API_KEY;
break;
case 'credentials':
apiKey = credentials?.apiKeys?.anthropic;
break;
}
// Warn if no API key found
if (!apiKey) {
logger.warn(`No API key found for profile "${profile.name}" with source "${source}"`);
}
// Authentication
if (profile.useAuthToken) {
env['ANTHROPIC_AUTH_TOKEN'] = apiKey;
} else {
env['ANTHROPIC_API_KEY'] = apiKey;
}
// Endpoint configuration
env['ANTHROPIC_BASE_URL'] = profile.baseUrl;
if (profile.timeoutMs) {
env['API_TIMEOUT_MS'] = String(profile.timeoutMs);
}
// Model mappings
if (profile.modelMappings?.haiku) {
env['ANTHROPIC_DEFAULT_HAIKU_MODEL'] = profile.modelMappings.haiku;
}
if (profile.modelMappings?.sonnet) {
env['ANTHROPIC_DEFAULT_SONNET_MODEL'] = profile.modelMappings.sonnet;
}
if (profile.modelMappings?.opus) {
env['ANTHROPIC_DEFAULT_OPUS_MODEL'] = profile.modelMappings.opus;
}
// Traffic control
if (profile.disableNonessentialTraffic) {
env['CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC'] = '1';
}
} else {
// Use direct Anthropic API - two modes:
// 1. API Key mode: ANTHROPIC_API_KEY from credentials/env
// 2. Claude Max plan: Uses CLI OAuth auth (SDK handles this automatically)
//
// IMPORTANT: Do NOT set any profile vars (base URL, model mappings, etc.)
// This ensures clean switching - only pass through what's in process.env
if (process.env.ANTHROPIC_API_KEY) {
env['ANTHROPIC_API_KEY'] = process.env.ANTHROPIC_API_KEY;
}
// If using Claude Max plan via CLI auth, the SDK handles auth automatically
// when no API key is provided. We don't set ANTHROPIC_AUTH_TOKEN here
// unless it was explicitly set in process.env (rare edge case).
if (process.env.ANTHROPIC_AUTH_TOKEN) {
env['ANTHROPIC_AUTH_TOKEN'] = process.env.ANTHROPIC_AUTH_TOKEN;
}
// Do NOT set ANTHROPIC_BASE_URL - let SDK use default Anthropic endpoint
// Do NOT set model mappings - use standard Claude model names
// Do NOT set CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC
}
// Always add system vars from process.env
for (const key of SYSTEM_ENV_VARS) {
if (process.env[key]) {
env[key] = process.env[key];
}
}
return env;
}
@@ -70,6 +177,8 @@ export class ClaudeProvider extends BaseProvider {
conversationHistory,
sdkSessionId,
thinkingLevel,
claudeApiProfile,
credentials,
} = options;
// Convert thinking level to token budget
@@ -82,7 +191,9 @@ export class ClaudeProvider extends BaseProvider {
maxTurns,
cwd,
// Pass only explicitly allowed environment variables to SDK
env: buildEnv(),
// When a profile is active, uses profile settings (clean switch)
// When no profile, uses direct Anthropic API (from process.env or CLI OAuth)
env: buildEnv(claudeApiProfile, credentials),
// Pass through allowedTools if provided by caller (decided by sdk-options.ts)
...(allowedTools && { allowedTools }),
// AUTONOMOUS MODE: Always bypass permissions for fully autonomous operation

View File

@@ -35,6 +35,7 @@ import {
type SubprocessOptions,
type WslCliResult,
} from '@automaker/platform';
import { calculateReasoningTimeout } from '@automaker/types';
import { createLogger, isAbortError } from '@automaker/utils';
import { execSync } from 'child_process';
import * as fs from 'fs';
@@ -107,6 +108,15 @@ export interface CliDetectionResult {
// Create logger for CLI operations
const cliLogger = createLogger('CliProvider');
/**
* Base timeout for CLI operations in milliseconds.
* CLI tools have longer startup and processing times compared to direct API calls,
* so we use a higher base timeout (120s) than the default provider timeout (30s).
* This is multiplied by reasoning effort multipliers when applicable.
* @see calculateReasoningTimeout from @automaker/types
*/
const CLI_BASE_TIMEOUT_MS = 120000;
/**
* Abstract base class for CLI-based providers
*
@@ -450,6 +460,10 @@ export abstract class CliProvider extends BaseProvider {
}
}
// Calculate dynamic timeout based on reasoning effort.
// This addresses GitHub issue #530 where reasoning models with 'xhigh' effort would timeout.
const timeout = calculateReasoningTimeout(options.reasoningEffort, CLI_BASE_TIMEOUT_MS);
// WSL strategy
if (this.useWsl && this.wslCliPath) {
const wslCwd = windowsToWslPath(cwd);
@@ -473,7 +487,7 @@ export abstract class CliProvider extends BaseProvider {
cwd, // Windows cwd for spawn
env: filteredEnv,
abortController: options.abortController,
timeout: 120000, // CLI operations may take longer
timeout,
};
}
@@ -488,7 +502,7 @@ export abstract class CliProvider extends BaseProvider {
cwd,
env: filteredEnv,
abortController: options.abortController,
timeout: 120000,
timeout,
};
}
@@ -501,7 +515,7 @@ export abstract class CliProvider extends BaseProvider {
cwd,
env: filteredEnv,
abortController: options.abortController,
timeout: 120000,
timeout,
};
}

View File

@@ -33,6 +33,8 @@ import {
CODEX_MODEL_MAP,
supportsReasoningEffort,
validateBareModelId,
calculateReasoningTimeout,
DEFAULT_TIMEOUT_MS,
type CodexApprovalPolicy,
type CodexSandboxMode,
type CodexAuthStatus,
@@ -91,7 +93,14 @@ const CODEX_ITEM_TYPES = {
const SYSTEM_PROMPT_LABEL = 'System instructions';
const HISTORY_HEADER = 'Current request:\n';
const TEXT_ENCODING = 'utf-8';
const DEFAULT_TIMEOUT_MS = 30000;
/**
* Default timeout for Codex CLI operations in milliseconds.
* This is the "no output" timeout - if the CLI doesn't produce any JSONL output
* for this duration, the process is killed. For reasoning models with high
* reasoning effort, this timeout is dynamically extended via calculateReasoningTimeout().
* @see calculateReasoningTimeout from @automaker/types
*/
const CODEX_CLI_TIMEOUT_MS = DEFAULT_TIMEOUT_MS;
const CONTEXT_WINDOW_256K = 256000;
const MAX_OUTPUT_32K = 32000;
const MAX_OUTPUT_16K = 16000;
@@ -814,13 +823,19 @@ export class CodexProvider extends BaseProvider {
envOverrides[OPENAI_API_KEY_ENV] = executionPlan.openAiApiKey;
}
// Calculate dynamic timeout based on reasoning effort.
// Higher reasoning effort (e.g., 'xhigh' for "xtra thinking" mode) requires more time
// for the model to generate reasoning tokens before producing output.
// This fixes GitHub issue #530 where features would get stuck with reasoning models.
const timeout = calculateReasoningTimeout(options.reasoningEffort, CODEX_CLI_TIMEOUT_MS);
const stream = spawnJSONLProcess({
command: commandPath,
args,
cwd: options.cwd,
env: envOverrides,
abortController: options.abortController,
timeout: DEFAULT_TIMEOUT_MS,
timeout,
stdinData: promptText, // Pass prompt via stdin
});

View File

@@ -44,7 +44,7 @@ export class CursorConfigManager {
// Return default config with all available models
return {
defaultModel: 'auto',
defaultModel: 'cursor-auto',
models: getAllCursorModelIds(),
};
}
@@ -77,7 +77,7 @@ export class CursorConfigManager {
* Get the default model
*/
getDefaultModel(): CursorModelId {
return this.config.defaultModel || 'auto';
return this.config.defaultModel || 'cursor-auto';
}
/**
@@ -93,7 +93,7 @@ export class CursorConfigManager {
* Get enabled models
*/
getEnabledModels(): CursorModelId[] {
return this.config.models || ['auto'];
return this.config.models || ['cursor-auto'];
}
/**
@@ -174,7 +174,7 @@ export class CursorConfigManager {
*/
reset(): void {
this.config = {
defaultModel: 'auto',
defaultModel: 'cursor-auto',
models: getAllCursorModelIds(),
};
this.saveConfig();

View File

@@ -20,6 +20,8 @@ import type {
ContentBlock,
ThinkingLevel,
ReasoningEffort,
ClaudeApiProfile,
Credentials,
} from '@automaker/types';
import { stripProviderPrefix } from '@automaker/types';
@@ -54,6 +56,10 @@ export interface SimpleQueryOptions {
readOnly?: boolean;
/** Setting sources for CLAUDE.md loading */
settingSources?: Array<'user' | 'project' | 'local'>;
/** Active Claude API profile for alternative endpoint configuration */
claudeApiProfile?: ClaudeApiProfile;
/** Credentials for resolving 'credentials' apiKeySource in Claude API profiles */
credentials?: Credentials;
}
/**
@@ -125,6 +131,8 @@ export async function simpleQuery(options: SimpleQueryOptions): Promise<SimpleQu
reasoningEffort: options.reasoningEffort,
readOnly: options.readOnly,
settingSources: options.settingSources,
claudeApiProfile: options.claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
credentials: options.credentials, // Pass credentials for resolving 'credentials' apiKeySource
};
for await (const msg of provider.executeQuery(providerOptions)) {
@@ -207,6 +215,8 @@ export async function streamingQuery(options: StreamingQueryOptions): Promise<Si
reasoningEffort: options.reasoningEffort,
readOnly: options.readOnly,
settingSources: options.settingSources,
claudeApiProfile: options.claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
credentials: options.credentials, // Pass credentials for resolving 'credentials' apiKeySource
};
for await (const msg of provider.executeQuery(providerOptions)) {

View File

@@ -6,8 +6,17 @@ import { createLogger } from '@automaker/utils';
const logger = createLogger('SpecRegeneration');
// Types for running generation
export type GenerationType = 'spec_regeneration' | 'feature_generation' | 'sync';
interface RunningGeneration {
isRunning: boolean;
type: GenerationType;
startedAt: string;
}
// Shared state for tracking generation status - scoped by project path
const runningProjects = new Map<string, boolean>();
const runningProjects = new Map<string, RunningGeneration>();
const abortControllers = new Map<string, AbortController>();
/**
@@ -17,16 +26,21 @@ export function getSpecRegenerationStatus(projectPath?: string): {
isRunning: boolean;
currentAbortController: AbortController | null;
projectPath?: string;
type?: GenerationType;
startedAt?: string;
} {
if (projectPath) {
const generation = runningProjects.get(projectPath);
return {
isRunning: runningProjects.get(projectPath) || false,
isRunning: generation?.isRunning || false,
currentAbortController: abortControllers.get(projectPath) || null,
projectPath,
type: generation?.type,
startedAt: generation?.startedAt,
};
}
// Fallback: check if any project is running (for backward compatibility)
const isAnyRunning = Array.from(runningProjects.values()).some((running) => running);
const isAnyRunning = Array.from(runningProjects.values()).some((g) => g.isRunning);
return { isRunning: isAnyRunning, currentAbortController: null };
}
@@ -46,10 +60,15 @@ export function getRunningProjectPath(): string | null {
export function setRunningState(
projectPath: string,
running: boolean,
controller: AbortController | null = null
controller: AbortController | null = null,
type: GenerationType = 'spec_regeneration'
): void {
if (running) {
runningProjects.set(projectPath, true);
runningProjects.set(projectPath, {
isRunning: true,
type,
startedAt: new Date().toISOString(),
});
if (controller) {
abortControllers.set(projectPath, controller);
}
@@ -59,6 +78,33 @@ export function setRunningState(
}
}
/**
* Get all running spec/feature generations for the running agents view
*/
export function getAllRunningGenerations(): Array<{
projectPath: string;
type: GenerationType;
startedAt: string;
}> {
const results: Array<{
projectPath: string;
type: GenerationType;
startedAt: string;
}> = [];
for (const [projectPath, generation] of runningProjects.entries()) {
if (generation.isRunning) {
results.push({
projectPath,
type: generation.type,
startedAt: generation.startedAt,
});
}
}
return results;
}
/**
* Helper to log authentication status
*/

View File

@@ -14,7 +14,12 @@ import { streamingQuery } from '../../providers/simple-query-service.js';
import { parseAndCreateFeatures } from './parse-and-create-features.js';
import { getAppSpecPath } from '@automaker/platform';
import type { SettingsService } from '../../services/settings-service.js';
import { getAutoLoadClaudeMdSetting } from '../../lib/settings-helpers.js';
import {
getAutoLoadClaudeMdSetting,
getPromptCustomization,
getActiveClaudeApiProfile,
} from '../../lib/settings-helpers.js';
import { FeatureLoader } from '../../services/feature-loader.js';
const logger = createLogger('SpecRegeneration');
@@ -53,38 +58,48 @@ export async function generateFeaturesFromSpec(
return;
}
// Get customized prompts from settings
const prompts = await getPromptCustomization(settingsService, '[FeatureGeneration]');
// Load existing features to prevent duplicates
const featureLoader = new FeatureLoader();
const existingFeatures = await featureLoader.getAll(projectPath);
logger.info(`Found ${existingFeatures.length} existing features to exclude from generation`);
// Build existing features context for the prompt
let existingFeaturesContext = '';
if (existingFeatures.length > 0) {
const featuresList = existingFeatures
.map(
(f) =>
`- "${f.title}" (ID: ${f.id}): ${f.description?.substring(0, 100) || 'No description'}`
)
.join('\n');
existingFeaturesContext = `
## EXISTING FEATURES (DO NOT REGENERATE THESE)
The following ${existingFeatures.length} features already exist in the project. You MUST NOT generate features that duplicate or overlap with these:
${featuresList}
CRITICAL INSTRUCTIONS:
- DO NOT generate any features with the same or similar titles as the existing features listed above
- DO NOT generate features that cover the same functionality as existing features
- ONLY generate NEW features that are not yet in the system
- If a feature from the roadmap already exists, skip it entirely
- Generate unique feature IDs that do not conflict with existing IDs: ${existingFeatures.map((f) => f.id).join(', ')}
`;
}
const prompt = `Based on this project specification:
${spec}
${existingFeaturesContext}
${prompts.appSpec.generateFeaturesFromSpecPrompt}
Generate a prioritized list of implementable features. For each feature provide:
1. **id**: A unique lowercase-hyphenated identifier
2. **category**: Functional category (e.g., "Core", "UI", "API", "Authentication", "Database")
3. **title**: Short descriptive title
4. **description**: What this feature does (2-3 sentences)
5. **priority**: 1 (high), 2 (medium), or 3 (low)
6. **complexity**: "simple", "moderate", or "complex"
7. **dependencies**: Array of feature IDs this depends on (can be empty)
Format as JSON:
{
"features": [
{
"id": "feature-id",
"category": "Feature Category",
"title": "Feature Title",
"description": "What it does",
"priority": 1,
"complexity": "moderate",
"dependencies": []
}
]
}
Generate ${featureCount} features that build on each other logically.
IMPORTANT: Do not ask for clarification. The specification is provided above. Generate the JSON immediately.`;
Generate ${featureCount} NEW features that build on each other logically. Remember: ONLY generate features that DO NOT already exist.`;
logger.info('========== PROMPT BEING SENT ==========');
logger.info(`Prompt length: ${prompt.length} chars`);
@@ -112,6 +127,12 @@ IMPORTANT: Do not ask for clarification. The specification is provided above. Ge
logger.info('Using model:', model);
// Get active Claude API profile for alternative endpoint configuration
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
settingsService,
'[FeatureGeneration]'
);
// Use streamingQuery with event callbacks
const result = await streamingQuery({
prompt,
@@ -123,6 +144,8 @@ IMPORTANT: Do not ask for clarification. The specification is provided above. Ge
thinkingLevel,
readOnly: true, // Feature generation only reads code, doesn't write
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
onText: (text) => {
logger.debug(`Feature text block received (${text.length} chars)`);
events.emit('spec-regeneration:event', {

View File

@@ -7,12 +7,7 @@
import * as secureFs from '../../lib/secure-fs.js';
import type { EventEmitter } from '../../lib/events.js';
import {
specOutputSchema,
specToXml,
getStructuredSpecPromptInstruction,
type SpecOutput,
} from '../../lib/app-spec-format.js';
import { specOutputSchema, specToXml, type SpecOutput } from '../../lib/app-spec-format.js';
import { createLogger } from '@automaker/utils';
import { DEFAULT_PHASE_MODELS, isCursorModel } from '@automaker/types';
import { resolvePhaseModel } from '@automaker/model-resolver';
@@ -21,7 +16,11 @@ import { streamingQuery } from '../../providers/simple-query-service.js';
import { generateFeaturesFromSpec } from './generate-features-from-spec.js';
import { ensureAutomakerDir, getAppSpecPath } from '@automaker/platform';
import type { SettingsService } from '../../services/settings-service.js';
import { getAutoLoadClaudeMdSetting } from '../../lib/settings-helpers.js';
import {
getAutoLoadClaudeMdSetting,
getPromptCustomization,
getActiveClaudeApiProfile,
} from '../../lib/settings-helpers.js';
const logger = createLogger('SpecRegeneration');
@@ -43,6 +42,9 @@ export async function generateSpec(
logger.info('analyzeProject:', analyzeProject);
logger.info('maxFeatures:', maxFeatures);
// Get customized prompts from settings
const prompts = await getPromptCustomization(settingsService, '[SpecRegeneration]');
// Build the prompt based on whether we should analyze the project
let analysisInstructions = '';
let techStackDefaults = '';
@@ -66,9 +68,7 @@ export async function generateSpec(
Use these technologies as the foundation for the specification.`;
}
const prompt = `You are helping to define a software project specification.
IMPORTANT: Never ask for clarification or additional information. Use the information provided and make reasonable assumptions to create the best possible specification. If details are missing, infer them based on common patterns and best practices.
const prompt = `${prompts.appSpec.generateSpecSystemPrompt}
Project Overview:
${projectOverview}
@@ -77,7 +77,7 @@ ${techStackDefaults}
${analysisInstructions}
${getStructuredSpecPromptInstruction()}`;
${prompts.appSpec.structuredSpecInstructions}`;
logger.info('========== PROMPT BEING SENT ==========');
logger.info(`Prompt length: ${prompt.length} chars`);
@@ -104,6 +104,12 @@ ${getStructuredSpecPromptInstruction()}`;
logger.info('Using model:', model);
// Get active Claude API profile for alternative endpoint configuration
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
settingsService,
'[SpecRegeneration]'
);
let responseText = '';
let structuredOutput: SpecOutput | null = null;
@@ -136,6 +142,8 @@ Your entire response should be valid JSON starting with { and ending with }. No
thinkingLevel,
readOnly: true, // Spec generation only reads code, we write the spec ourselves
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
outputFormat: useStructuredOutput
? {
type: 'json_schema',
@@ -205,19 +213,33 @@ Your entire response should be valid JSON starting with { and ending with }. No
xmlContent = responseText.substring(xmlStart, xmlEnd + '</project_specification>'.length);
logger.info(`Extracted XML content: ${xmlContent.length} chars (from position ${xmlStart})`);
} else {
// No valid XML structure found in the response text
// This happens when structured output was expected but not received, and the agent
// output conversational text instead of XML (e.g., "The project directory appears to be empty...")
// We should NOT save this conversational text as it's not a valid spec
logger.error('❌ Response does not contain valid <project_specification> XML structure');
logger.error(
'This typically happens when structured output failed and the agent produced conversational text instead of XML'
);
throw new Error(
'Failed to generate spec: No valid XML structure found in response. ' +
'The response contained conversational text but no <project_specification> tags. ' +
'Please try again.'
);
// No XML found, try JSON extraction
logger.warn('⚠️ No XML tags found, attempting JSON extraction...');
const extractedJson = extractJson<SpecOutput>(responseText, { logger });
if (
extractedJson &&
typeof extractedJson.project_name === 'string' &&
typeof extractedJson.overview === 'string' &&
Array.isArray(extractedJson.technology_stack) &&
Array.isArray(extractedJson.core_capabilities) &&
Array.isArray(extractedJson.implemented_features)
) {
logger.info('✅ Successfully extracted JSON from response text');
xmlContent = specToXml(extractedJson);
logger.info(`✅ Converted extracted JSON to XML: ${xmlContent.length} chars`);
} else {
// Neither XML nor valid JSON found
logger.error('❌ Response does not contain valid XML or JSON structure');
logger.error(
'This typically happens when structured output failed and the agent produced conversational text instead of structured output'
);
throw new Error(
'Failed to generate spec: No valid XML or JSON structure found in response. ' +
'The response contained conversational text but no <project_specification> tags or valid JSON. ' +
'Please try again.'
);
}
}
}

View File

@@ -7,6 +7,7 @@ import type { EventEmitter } from '../../lib/events.js';
import { createCreateHandler } from './routes/create.js';
import { createGenerateHandler } from './routes/generate.js';
import { createGenerateFeaturesHandler } from './routes/generate-features.js';
import { createSyncHandler } from './routes/sync.js';
import { createStopHandler } from './routes/stop.js';
import { createStatusHandler } from './routes/status.js';
import type { SettingsService } from '../../services/settings-service.js';
@@ -20,6 +21,7 @@ export function createSpecRegenerationRoutes(
router.post('/create', createCreateHandler(events));
router.post('/generate', createGenerateHandler(events, settingsService));
router.post('/generate-features', createGenerateFeaturesHandler(events, settingsService));
router.post('/sync', createSyncHandler(events, settingsService));
router.post('/stop', createStopHandler());
router.get('/status', createStatusHandler());

View File

@@ -5,9 +5,10 @@
import path from 'path';
import * as secureFs from '../../lib/secure-fs.js';
import type { EventEmitter } from '../../lib/events.js';
import { createLogger } from '@automaker/utils';
import { createLogger, atomicWriteJson, DEFAULT_BACKUP_COUNT } from '@automaker/utils';
import { getFeaturesDir } from '@automaker/platform';
import { extractJsonWithArray } from '../../lib/json-extractor.js';
import { getNotificationService } from '../../services/notification-service.js';
const logger = createLogger('SpecRegeneration');
@@ -73,10 +74,10 @@ export async function parseAndCreateFeatures(
updatedAt: new Date().toISOString(),
};
await secureFs.writeFile(
path.join(featureDir, 'feature.json'),
JSON.stringify(featureData, null, 2)
);
// Use atomic write with backup support for crash protection
await atomicWriteJson(path.join(featureDir, 'feature.json'), featureData, {
backupCount: DEFAULT_BACKUP_COUNT,
});
createdFeatures.push({ id: feature.id, title: feature.title });
}
@@ -88,6 +89,15 @@ export async function parseAndCreateFeatures(
message: `Spec regeneration complete! Created ${createdFeatures.length} features.`,
projectPath: projectPath,
});
// Create notification for spec generation completion
const notificationService = getNotificationService();
await notificationService.createNotification({
type: 'spec_regeneration_complete',
title: 'Spec Generation Complete',
message: `Created ${createdFeatures.length} features from the project specification.`,
projectPath: projectPath,
});
} catch (error) {
logger.error('❌ parseAndCreateFeatures() failed:');
logger.error('Error:', error);

View File

@@ -50,7 +50,7 @@ export function createGenerateFeaturesHandler(
logAuthStatus('Before starting feature generation');
const abortController = new AbortController();
setRunningState(projectPath, true, abortController);
setRunningState(projectPath, true, abortController, 'feature_generation');
logger.info('Starting background feature generation task...');
generateFeaturesFromSpec(projectPath, events, abortController, maxFeatures, settingsService)

View File

@@ -0,0 +1,76 @@
/**
* POST /sync endpoint - Sync spec with codebase and features
*/
import type { Request, Response } from 'express';
import type { EventEmitter } from '../../../lib/events.js';
import { createLogger } from '@automaker/utils';
import {
getSpecRegenerationStatus,
setRunningState,
logAuthStatus,
logError,
getErrorMessage,
} from '../common.js';
import { syncSpec } from '../sync-spec.js';
import type { SettingsService } from '../../../services/settings-service.js';
const logger = createLogger('SpecSync');
export function createSyncHandler(events: EventEmitter, settingsService?: SettingsService) {
return async (req: Request, res: Response): Promise<void> => {
logger.info('========== /sync endpoint called ==========');
logger.debug('Request body:', JSON.stringify(req.body, null, 2));
try {
const { projectPath } = req.body as {
projectPath: string;
};
logger.debug('projectPath:', projectPath);
if (!projectPath) {
logger.error('Missing projectPath parameter');
res.status(400).json({ success: false, error: 'projectPath required' });
return;
}
const { isRunning } = getSpecRegenerationStatus(projectPath);
if (isRunning) {
logger.warn('Generation/sync already running for project:', projectPath);
res.json({ success: false, error: 'Operation already running for this project' });
return;
}
logAuthStatus('Before starting spec sync');
const abortController = new AbortController();
setRunningState(projectPath, true, abortController, 'sync');
logger.info('Starting background spec sync task...');
syncSpec(projectPath, events, abortController, settingsService)
.then((result) => {
logger.info('Spec sync completed successfully');
logger.info('Result:', JSON.stringify(result, null, 2));
})
.catch((error) => {
logError(error, 'Spec sync failed with error');
events.emit('spec-regeneration:event', {
type: 'spec_regeneration_error',
error: getErrorMessage(error),
projectPath,
});
})
.finally(() => {
logger.info('Spec sync task finished (success or error)');
setRunningState(projectPath, false, null);
});
logger.info('Returning success response (sync running in background)');
res.json({ success: true });
} catch (error) {
logError(error, 'Sync route handler failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,318 @@
/**
* Sync spec with current codebase and feature state
*
* Updates the spec file based on:
* - Completed Automaker features
* - Code analysis for tech stack and implementations
* - Roadmap phase status updates
*/
import * as secureFs from '../../lib/secure-fs.js';
import type { EventEmitter } from '../../lib/events.js';
import { createLogger } from '@automaker/utils';
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
import { resolvePhaseModel } from '@automaker/model-resolver';
import { streamingQuery } from '../../providers/simple-query-service.js';
import { getAppSpecPath } from '@automaker/platform';
import type { SettingsService } from '../../services/settings-service.js';
import {
getAutoLoadClaudeMdSetting,
getActiveClaudeApiProfile,
} from '../../lib/settings-helpers.js';
import { FeatureLoader } from '../../services/feature-loader.js';
import {
extractImplementedFeatures,
extractTechnologyStack,
extractRoadmapPhases,
updateImplementedFeaturesSection,
updateTechnologyStack,
updateRoadmapPhaseStatus,
type ImplementedFeature,
type RoadmapPhase,
} from '../../lib/xml-extractor.js';
import { getNotificationService } from '../../services/notification-service.js';
const logger = createLogger('SpecSync');
/**
* Result of a sync operation
*/
export interface SyncResult {
techStackUpdates: {
added: string[];
removed: string[];
};
implementedFeaturesUpdates: {
addedFromFeatures: string[];
removed: string[];
};
roadmapUpdates: Array<{ phaseName: string; newStatus: string }>;
summary: string;
}
/**
* Sync the spec with current codebase and feature state
*/
export async function syncSpec(
projectPath: string,
events: EventEmitter,
abortController: AbortController,
settingsService?: SettingsService
): Promise<SyncResult> {
logger.info('========== syncSpec() started ==========');
logger.info('projectPath:', projectPath);
const result: SyncResult = {
techStackUpdates: { added: [], removed: [] },
implementedFeaturesUpdates: { addedFromFeatures: [], removed: [] },
roadmapUpdates: [],
summary: '',
};
// Read existing spec
const specPath = getAppSpecPath(projectPath);
let specContent: string;
try {
specContent = (await secureFs.readFile(specPath, 'utf-8')) as string;
logger.info(`Spec loaded successfully (${specContent.length} chars)`);
} catch (readError) {
logger.error('Failed to read spec file:', readError);
events.emit('spec-regeneration:event', {
type: 'spec_regeneration_error',
error: 'No project spec found. Create or regenerate spec first.',
projectPath,
});
throw new Error('No project spec found');
}
events.emit('spec-regeneration:event', {
type: 'spec_regeneration_progress',
content: '[Phase: sync] Starting spec sync...\n',
projectPath,
});
// Extract current state from spec
const currentImplementedFeatures = extractImplementedFeatures(specContent);
const currentTechStack = extractTechnologyStack(specContent);
const currentRoadmapPhases = extractRoadmapPhases(specContent);
logger.info(`Current spec has ${currentImplementedFeatures.length} implemented features`);
logger.info(`Current spec has ${currentTechStack.length} technologies`);
logger.info(`Current spec has ${currentRoadmapPhases.length} roadmap phases`);
// Load completed Automaker features
const featureLoader = new FeatureLoader();
const allFeatures = await featureLoader.getAll(projectPath);
const completedFeatures = allFeatures.filter(
(f) => f.status === 'completed' || f.status === 'verified'
);
logger.info(`Found ${completedFeatures.length} completed/verified features in Automaker`);
events.emit('spec-regeneration:event', {
type: 'spec_regeneration_progress',
content: `Found ${completedFeatures.length} completed features to sync...\n`,
projectPath,
});
// Build new implemented features list from completed Automaker features
const newImplementedFeatures: ImplementedFeature[] = [];
const existingNames = new Set(currentImplementedFeatures.map((f) => f.name.toLowerCase()));
for (const feature of completedFeatures) {
const name = feature.title || `Feature: ${feature.id}`;
if (!existingNames.has(name.toLowerCase())) {
newImplementedFeatures.push({
name,
description: feature.description || '',
});
result.implementedFeaturesUpdates.addedFromFeatures.push(name);
}
}
// Merge: keep existing + add new from completed features
const mergedFeatures = [...currentImplementedFeatures, ...newImplementedFeatures];
// Update spec with merged features
if (result.implementedFeaturesUpdates.addedFromFeatures.length > 0) {
specContent = updateImplementedFeaturesSection(specContent, mergedFeatures);
logger.info(
`Added ${result.implementedFeaturesUpdates.addedFromFeatures.length} features to spec`
);
}
// Analyze codebase for tech stack updates using AI
events.emit('spec-regeneration:event', {
type: 'spec_regeneration_progress',
content: 'Analyzing codebase for technology updates...\n',
projectPath,
});
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
projectPath,
settingsService,
'[SpecSync]'
);
const settings = await settingsService?.getGlobalSettings();
const phaseModelEntry =
settings?.phaseModels?.specGenerationModel || DEFAULT_PHASE_MODELS.specGenerationModel;
const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
// Get active Claude API profile for alternative endpoint configuration
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
settingsService,
'[SpecSync]'
);
// Use AI to analyze tech stack
const techAnalysisPrompt = `Analyze this project and return ONLY a JSON object with the current technology stack.
Current known technologies: ${currentTechStack.join(', ')}
Look at package.json, config files, and source code to identify:
- Frameworks (React, Vue, Express, etc.)
- Languages (TypeScript, JavaScript, Python, etc.)
- Build tools (Vite, Webpack, etc.)
- Databases (PostgreSQL, MongoDB, etc.)
- Key libraries and tools
Return ONLY this JSON format, no other text:
{
"technologies": ["Technology 1", "Technology 2", ...]
}`;
try {
const techResult = await streamingQuery({
prompt: techAnalysisPrompt,
model,
cwd: projectPath,
maxTurns: 10,
allowedTools: ['Read', 'Glob', 'Grep'],
abortController,
thinkingLevel,
readOnly: true,
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
onText: (text) => {
logger.debug(`Tech analysis text: ${text.substring(0, 100)}`);
},
});
// Parse tech stack from response
const jsonMatch = techResult.text.match(/\{[\s\S]*"technologies"[\s\S]*\}/);
if (jsonMatch) {
const parsed = JSON.parse(jsonMatch[0]);
if (Array.isArray(parsed.technologies)) {
const newTechStack = parsed.technologies as string[];
// Calculate differences
const currentSet = new Set(currentTechStack.map((t) => t.toLowerCase()));
const newSet = new Set(newTechStack.map((t) => t.toLowerCase()));
for (const tech of newTechStack) {
if (!currentSet.has(tech.toLowerCase())) {
result.techStackUpdates.added.push(tech);
}
}
for (const tech of currentTechStack) {
if (!newSet.has(tech.toLowerCase())) {
result.techStackUpdates.removed.push(tech);
}
}
// Update spec with new tech stack if there are changes
if (
result.techStackUpdates.added.length > 0 ||
result.techStackUpdates.removed.length > 0
) {
specContent = updateTechnologyStack(specContent, newTechStack);
logger.info(
`Updated tech stack: +${result.techStackUpdates.added.length}, -${result.techStackUpdates.removed.length}`
);
}
}
}
} catch (error) {
logger.warn('Failed to analyze tech stack:', error);
// Continue with other sync operations
}
// Update roadmap phase statuses based on completed features
events.emit('spec-regeneration:event', {
type: 'spec_regeneration_progress',
content: 'Checking roadmap phase statuses...\n',
projectPath,
});
// For each phase, check if all its features are completed
// This is a heuristic - we check if the phase name appears in any feature titles/descriptions
for (const phase of currentRoadmapPhases) {
if (phase.status === 'completed') continue; // Already completed
// Check if this phase should be marked as completed
// A phase is considered complete if we have completed features that mention it
const phaseNameLower = phase.name.toLowerCase();
const relatedCompletedFeatures = completedFeatures.filter(
(f) =>
f.title?.toLowerCase().includes(phaseNameLower) ||
f.description?.toLowerCase().includes(phaseNameLower) ||
f.category?.toLowerCase().includes(phaseNameLower)
);
// If we have related completed features and the phase is still pending/in_progress,
// update it to in_progress or completed based on feature count
if (relatedCompletedFeatures.length > 0 && phase.status !== 'completed') {
const newStatus = 'in_progress';
specContent = updateRoadmapPhaseStatus(specContent, phase.name, newStatus);
result.roadmapUpdates.push({ phaseName: phase.name, newStatus });
logger.info(`Updated phase "${phase.name}" to ${newStatus}`);
}
}
// Save updated spec
await secureFs.writeFile(specPath, specContent, 'utf-8');
logger.info('Spec saved successfully');
// Build summary
const summaryParts: string[] = [];
if (result.implementedFeaturesUpdates.addedFromFeatures.length > 0) {
summaryParts.push(
`Added ${result.implementedFeaturesUpdates.addedFromFeatures.length} implemented features`
);
}
if (result.techStackUpdates.added.length > 0) {
summaryParts.push(`Added ${result.techStackUpdates.added.length} technologies`);
}
if (result.techStackUpdates.removed.length > 0) {
summaryParts.push(`Removed ${result.techStackUpdates.removed.length} technologies`);
}
if (result.roadmapUpdates.length > 0) {
summaryParts.push(`Updated ${result.roadmapUpdates.length} roadmap phases`);
}
result.summary = summaryParts.length > 0 ? summaryParts.join(', ') : 'Spec is already up to date';
// Create notification
const notificationService = getNotificationService();
await notificationService.createNotification({
type: 'spec_regeneration_complete',
title: 'Spec Sync Complete',
message: result.summary,
projectPath,
});
events.emit('spec-regeneration:event', {
type: 'spec_regeneration_complete',
message: `Spec sync complete! ${result.summary}`,
projectPath,
});
logger.info('========== syncSpec() completed ==========');
logger.info('Summary:', result.summary);
return result;
}

View File

@@ -117,9 +117,27 @@ export function createAuthRoutes(): Router {
*
* Returns whether the current request is authenticated.
* Used by the UI to determine if login is needed.
*
* If AUTOMAKER_AUTO_LOGIN=true is set, automatically creates a session
* for unauthenticated requests (useful for development).
*/
router.get('/status', (req, res) => {
const authenticated = isRequestAuthenticated(req);
router.get('/status', async (req, res) => {
let authenticated = isRequestAuthenticated(req);
// Auto-login for development: create session automatically if enabled
// Only works in non-production environments as a safeguard
if (
!authenticated &&
process.env.AUTOMAKER_AUTO_LOGIN === 'true' &&
process.env.NODE_ENV !== 'production'
) {
const sessionToken = await createSession();
const cookieOptions = getSessionCookieOptions();
const cookieName = getSessionCookieName();
res.cookie(cookieName, sessionToken, cookieOptions);
authenticated = true;
}
res.json({
success: true,
authenticated,

View File

@@ -10,6 +10,8 @@ import { validatePathParams } from '../../middleware/validate-paths.js';
import { createStopFeatureHandler } from './routes/stop-feature.js';
import { createStatusHandler } from './routes/status.js';
import { createRunFeatureHandler } from './routes/run-feature.js';
import { createStartHandler } from './routes/start.js';
import { createStopHandler } from './routes/stop.js';
import { createVerifyFeatureHandler } from './routes/verify-feature.js';
import { createResumeFeatureHandler } from './routes/resume-feature.js';
import { createContextExistsHandler } from './routes/context-exists.js';
@@ -22,6 +24,10 @@ import { createResumeInterruptedHandler } from './routes/resume-interrupted.js';
export function createAutoModeRoutes(autoModeService: AutoModeService): Router {
const router = Router();
// Auto loop control routes
router.post('/start', validatePathParams('projectPath'), createStartHandler(autoModeService));
router.post('/stop', validatePathParams('projectPath'), createStopHandler(autoModeService));
router.post('/stop-feature', createStopFeatureHandler(autoModeService));
router.post('/status', validatePathParams('projectPath?'), createStatusHandler(autoModeService));
router.post(

View File

@@ -0,0 +1,54 @@
/**
* POST /start endpoint - Start auto mode loop for a project
*/
import type { Request, Response } from 'express';
import type { AutoModeService } from '../../../services/auto-mode-service.js';
import { createLogger } from '@automaker/utils';
import { getErrorMessage, logError } from '../common.js';
const logger = createLogger('AutoMode');
export function createStartHandler(autoModeService: AutoModeService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, maxConcurrency } = req.body as {
projectPath: string;
maxConcurrency?: number;
};
if (!projectPath) {
res.status(400).json({
success: false,
error: 'projectPath is required',
});
return;
}
// Check if already running
if (autoModeService.isAutoLoopRunningForProject(projectPath)) {
res.json({
success: true,
message: 'Auto mode is already running for this project',
alreadyRunning: true,
});
return;
}
// Start the auto loop for this project
await autoModeService.startAutoLoopForProject(projectPath, maxConcurrency ?? 3);
logger.info(
`Started auto loop for project: ${projectPath} with maxConcurrency: ${maxConcurrency ?? 3}`
);
res.json({
success: true,
message: `Auto mode started with max ${maxConcurrency ?? 3} concurrent features`,
});
} catch (error) {
logError(error, 'Start auto mode failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -1,5 +1,8 @@
/**
* POST /status endpoint - Get auto mode status
*
* If projectPath is provided, returns per-project status including autoloop state.
* If no projectPath, returns global status for backward compatibility.
*/
import type { Request, Response } from 'express';
@@ -9,10 +12,30 @@ import { getErrorMessage, logError } from '../common.js';
export function createStatusHandler(autoModeService: AutoModeService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath } = req.body as { projectPath?: string };
// If projectPath is provided, return per-project status
if (projectPath) {
const projectStatus = autoModeService.getStatusForProject(projectPath);
res.json({
success: true,
isRunning: projectStatus.runningCount > 0,
isAutoLoopRunning: projectStatus.isAutoLoopRunning,
runningFeatures: projectStatus.runningFeatures,
runningCount: projectStatus.runningCount,
maxConcurrency: projectStatus.maxConcurrency,
projectPath,
});
return;
}
// Fall back to global status for backward compatibility
const status = autoModeService.getStatus();
const activeProjects = autoModeService.getActiveAutoLoopProjects();
res.json({
success: true,
...status,
activeAutoLoopProjects: activeProjects,
});
} catch (error) {
logError(error, 'Get status failed');

View File

@@ -0,0 +1,54 @@
/**
* POST /stop endpoint - Stop auto mode loop for a project
*/
import type { Request, Response } from 'express';
import type { AutoModeService } from '../../../services/auto-mode-service.js';
import { createLogger } from '@automaker/utils';
import { getErrorMessage, logError } from '../common.js';
const logger = createLogger('AutoMode');
export function createStopHandler(autoModeService: AutoModeService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath } = req.body as {
projectPath: string;
};
if (!projectPath) {
res.status(400).json({
success: false,
error: 'projectPath is required',
});
return;
}
// Check if running
if (!autoModeService.isAutoLoopRunningForProject(projectPath)) {
res.json({
success: true,
message: 'Auto mode is not running for this project',
wasRunning: false,
});
return;
}
// Stop the auto loop for this project
const runningCount = await autoModeService.stopAutoLoopForProject(projectPath);
logger.info(
`Stopped auto loop for project: ${projectPath}, ${runningCount} features still running`
);
res.json({
success: true,
message: 'Auto mode stopped',
runningFeaturesCount: runningCount,
});
} catch (error) {
logError(error, 'Stop auto mode failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -3,12 +3,31 @@
*/
import { createLogger } from '@automaker/utils';
import { ensureAutomakerDir, getAutomakerDir } from '@automaker/platform';
import * as secureFs from '../../lib/secure-fs.js';
import path from 'path';
import type { BacklogPlanResult } from '@automaker/types';
const logger = createLogger('BacklogPlan');
// State for tracking running generation
let isRunning = false;
let currentAbortController: AbortController | null = null;
let runningDetails: {
projectPath: string;
prompt: string;
model?: string;
startedAt: string;
} | null = null;
const BACKLOG_PLAN_FILENAME = 'backlog-plan.json';
export interface StoredBacklogPlan {
savedAt: string;
prompt: string;
model?: string;
result: BacklogPlanResult;
}
export function getBacklogPlanStatus(): { isRunning: boolean } {
return { isRunning };
@@ -16,20 +35,125 @@ export function getBacklogPlanStatus(): { isRunning: boolean } {
export function setRunningState(running: boolean, abortController?: AbortController | null): void {
isRunning = running;
if (!running) {
runningDetails = null;
}
if (abortController !== undefined) {
currentAbortController = abortController;
}
}
export function setRunningDetails(
details: {
projectPath: string;
prompt: string;
model?: string;
startedAt: string;
} | null
): void {
runningDetails = details;
}
export function getRunningDetails(): {
projectPath: string;
prompt: string;
model?: string;
startedAt: string;
} | null {
return runningDetails;
}
function getBacklogPlanPath(projectPath: string): string {
return path.join(getAutomakerDir(projectPath), BACKLOG_PLAN_FILENAME);
}
export async function saveBacklogPlan(projectPath: string, plan: StoredBacklogPlan): Promise<void> {
await ensureAutomakerDir(projectPath);
const filePath = getBacklogPlanPath(projectPath);
await secureFs.writeFile(filePath, JSON.stringify(plan, null, 2), 'utf-8');
}
export async function loadBacklogPlan(projectPath: string): Promise<StoredBacklogPlan | null> {
try {
const filePath = getBacklogPlanPath(projectPath);
const raw = await secureFs.readFile(filePath, 'utf-8');
const parsed = JSON.parse(raw as string) as StoredBacklogPlan;
if (!Array.isArray(parsed?.result?.changes)) {
return null;
}
return parsed;
} catch {
return null;
}
}
export async function clearBacklogPlan(projectPath: string): Promise<void> {
try {
const filePath = getBacklogPlanPath(projectPath);
await secureFs.unlink(filePath);
} catch {
// ignore missing file
}
}
export function getAbortController(): AbortController | null {
return currentAbortController;
}
export function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
/**
* Map SDK/CLI errors to user-friendly messages
*/
export function mapBacklogPlanError(rawMessage: string): string {
// Claude Code spawn failures
if (
rawMessage.includes('Failed to spawn Claude Code process') ||
rawMessage.includes('spawn node ENOENT') ||
rawMessage.includes('Claude Code executable not found') ||
rawMessage.includes('Claude Code native binary not found')
) {
return 'Claude CLI could not be launched. Make sure the Claude CLI is installed and available in PATH, or check that Node.js is correctly installed. Try running "which claude" or "claude --version" in your terminal to verify.';
}
return String(error);
// Claude Code process crash
if (rawMessage.includes('Claude Code process exited')) {
return 'Claude exited unexpectedly. Try again. If it keeps happening, re-run `claude login` or update your API key in Setup.';
}
// Rate limiting
if (rawMessage.toLowerCase().includes('rate limit') || rawMessage.includes('429')) {
return 'Rate limited. Please wait a moment and try again.';
}
// Network errors
if (
rawMessage.toLowerCase().includes('network') ||
rawMessage.toLowerCase().includes('econnrefused') ||
rawMessage.toLowerCase().includes('timeout')
) {
return 'Network error. Check your internet connection and try again.';
}
// Authentication errors
if (
rawMessage.toLowerCase().includes('not authenticated') ||
rawMessage.toLowerCase().includes('unauthorized') ||
rawMessage.includes('401')
) {
return 'Authentication failed. Please check your API key or run `claude login` to authenticate.';
}
// Return original message for unknown errors
return rawMessage;
}
export function getErrorMessage(error: unknown): string {
let rawMessage: string;
if (error instanceof Error) {
rawMessage = error.message;
} else {
rawMessage = String(error);
}
return mapBacklogPlanError(rawMessage);
}
export function logError(error: unknown, context: string): void {

View File

@@ -17,9 +17,19 @@ import { resolvePhaseModel } from '@automaker/model-resolver';
import { FeatureLoader } from '../../services/feature-loader.js';
import { ProviderFactory } from '../../providers/provider-factory.js';
import { extractJsonWithArray } from '../../lib/json-extractor.js';
import { logger, setRunningState, getErrorMessage } from './common.js';
import {
logger,
setRunningState,
setRunningDetails,
getErrorMessage,
saveBacklogPlan,
} from './common.js';
import type { SettingsService } from '../../services/settings-service.js';
import { getAutoLoadClaudeMdSetting, getPromptCustomization } from '../../lib/settings-helpers.js';
import {
getAutoLoadClaudeMdSetting,
getPromptCustomization,
getActiveClaudeApiProfile,
} from '../../lib/settings-helpers.js';
const featureLoader = new FeatureLoader();
@@ -155,6 +165,12 @@ ${userPrompt}`;
finalSystemPrompt = undefined; // System prompt is now embedded in the user prompt
}
// Get active Claude API profile for alternative endpoint configuration
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
settingsService,
'[BacklogPlan]'
);
// Execute the query
const stream = provider.executeQuery({
prompt: finalPrompt,
@@ -167,6 +183,8 @@ ${userPrompt}`;
settingSources: autoLoadClaudeMd ? ['user', 'project'] : undefined,
readOnly: true, // Plan generation only generates text, doesn't write files
thinkingLevel, // Pass thinking level for extended thinking
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
});
let responseText = '';
@@ -200,6 +218,13 @@ ${userPrompt}`;
// Parse the response
const result = parsePlanResponse(responseText);
await saveBacklogPlan(projectPath, {
savedAt: new Date().toISOString(),
prompt,
model: effectiveModel,
result,
});
events.emit('backlog-plan:event', {
type: 'backlog_plan_complete',
result,
@@ -218,5 +243,6 @@ ${userPrompt}`;
throw error;
} finally {
setRunningState(false, null);
setRunningDetails(null);
}
}

View File

@@ -9,6 +9,7 @@ import { createGenerateHandler } from './routes/generate.js';
import { createStopHandler } from './routes/stop.js';
import { createStatusHandler } from './routes/status.js';
import { createApplyHandler } from './routes/apply.js';
import { createClearHandler } from './routes/clear.js';
import type { SettingsService } from '../../services/settings-service.js';
export function createBacklogPlanRoutes(
@@ -23,8 +24,9 @@ export function createBacklogPlanRoutes(
createGenerateHandler(events, settingsService)
);
router.post('/stop', createStopHandler());
router.get('/status', createStatusHandler());
router.get('/status', validatePathParams('projectPath'), createStatusHandler());
router.post('/apply', validatePathParams('projectPath'), createApplyHandler());
router.post('/clear', validatePathParams('projectPath'), createClearHandler());
return router;
}

View File

@@ -5,7 +5,7 @@
import type { Request, Response } from 'express';
import type { BacklogPlanResult, BacklogChange, Feature } from '@automaker/types';
import { FeatureLoader } from '../../../services/feature-loader.js';
import { getErrorMessage, logError, logger } from '../common.js';
import { clearBacklogPlan, getErrorMessage, logError, logger } from '../common.js';
const featureLoader = new FeatureLoader();
@@ -147,6 +147,17 @@ export function createApplyHandler() {
}
}
// Clear the plan before responding
try {
await clearBacklogPlan(projectPath);
} catch (error) {
logger.warn(
`[BacklogPlan] Failed to clear backlog plan after apply:`,
getErrorMessage(error)
);
// Don't throw - operation succeeded, just cleanup failed
}
res.json({
success: true,
appliedChanges,

View File

@@ -0,0 +1,25 @@
/**
* POST /clear endpoint - Clear saved backlog plan
*/
import type { Request, Response } from 'express';
import { clearBacklogPlan, getErrorMessage, logError } from '../common.js';
export function createClearHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath } = req.body as { projectPath: string };
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath required' });
return;
}
await clearBacklogPlan(projectPath);
res.json({ success: true });
} catch (error) {
logError(error, 'Clear backlog plan failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -4,7 +4,13 @@
import type { Request, Response } from 'express';
import type { EventEmitter } from '../../../lib/events.js';
import { getBacklogPlanStatus, setRunningState, getErrorMessage, logError } from '../common.js';
import {
getBacklogPlanStatus,
setRunningState,
setRunningDetails,
getErrorMessage,
logError,
} from '../common.js';
import { generateBacklogPlan } from '../generate-plan.js';
import type { SettingsService } from '../../../services/settings-service.js';
@@ -37,20 +43,26 @@ export function createGenerateHandler(events: EventEmitter, settingsService?: Se
}
setRunningState(true);
setRunningDetails({
projectPath,
prompt,
model,
startedAt: new Date().toISOString(),
});
const abortController = new AbortController();
setRunningState(true, abortController);
// Start generation in background
// Note: generateBacklogPlan handles its own error event emission,
// so we only log here to avoid duplicate error toasts
generateBacklogPlan(projectPath, prompt, events, abortController, settingsService, model)
.catch((error) => {
// Just log - error event already emitted by generateBacklogPlan
logError(error, 'Generate backlog plan failed (background)');
events.emit('backlog-plan:event', {
type: 'backlog_plan_error',
error: getErrorMessage(error),
});
})
.finally(() => {
setRunningState(false, null);
setRunningDetails(null);
});
res.json({ success: true });

View File

@@ -3,13 +3,15 @@
*/
import type { Request, Response } from 'express';
import { getBacklogPlanStatus, getErrorMessage, logError } from '../common.js';
import { getBacklogPlanStatus, loadBacklogPlan, getErrorMessage, logError } from '../common.js';
export function createStatusHandler() {
return async (_req: Request, res: Response): Promise<void> => {
return async (req: Request, res: Response): Promise<void> => {
try {
const status = getBacklogPlanStatus();
res.json({ success: true, ...status });
const projectPath = typeof req.query.projectPath === 'string' ? req.query.projectPath : '';
const savedPlan = projectPath ? await loadBacklogPlan(projectPath) : null;
res.json({ success: true, ...status, savedPlan });
} catch (error) {
logError(error, 'Get backlog plan status failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });

View File

@@ -3,7 +3,13 @@
*/
import type { Request, Response } from 'express';
import { getAbortController, setRunningState, getErrorMessage, logError } from '../common.js';
import {
getAbortController,
setRunningState,
setRunningDetails,
getErrorMessage,
logError,
} from '../common.js';
export function createStopHandler() {
return async (_req: Request, res: Response): Promise<void> => {
@@ -12,6 +18,7 @@ export function createStopHandler() {
if (abortController) {
abortController.abort();
setRunningState(false, null);
setRunningDetails(null);
}
res.json({ success: true });
} catch (error) {

View File

@@ -19,7 +19,11 @@ import { simpleQuery } from '../../../providers/simple-query-service.js';
import * as secureFs from '../../../lib/secure-fs.js';
import * as path from 'path';
import type { SettingsService } from '../../../services/settings-service.js';
import { getAutoLoadClaudeMdSetting } from '../../../lib/settings-helpers.js';
import {
getAutoLoadClaudeMdSetting,
getPromptCustomization,
getActiveClaudeApiProfile,
} from '../../../lib/settings-helpers.js';
const logger = createLogger('DescribeFile');
@@ -130,11 +134,12 @@ export function createDescribeFileHandler(
// Get the filename for context
const fileName = path.basename(resolvedPath);
// Get customized prompts from settings
const prompts = await getPromptCustomization(settingsService, '[DescribeFile]');
// Build prompt with file content passed as structured data
// The file content is included directly, not via tool invocation
const prompt = `Analyze the following file and provide a 1-2 sentence description suitable for use as context in an AI coding assistant. Focus on what the file contains, its purpose, and why an AI agent might want to use this context in the future (e.g., "API documentation for the authentication endpoints", "Configuration file for database connections", "Coding style guidelines for the project").
Respond with ONLY the description text, no additional formatting, preamble, or explanation.
const prompt = `${prompts.contextDescription.describeFilePrompt}
File: ${fileName}${truncated ? ' (truncated)' : ''}
@@ -161,6 +166,12 @@ ${contentToAnalyze}`;
logger.info(`Resolved model: ${model}, thinkingLevel: ${thinkingLevel}`);
// Get active Claude API profile for alternative endpoint configuration
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
settingsService,
'[DescribeFile]'
);
// Use simpleQuery - provider abstraction handles routing to correct provider
const result = await simpleQuery({
prompt,
@@ -171,6 +182,8 @@ ${contentToAnalyze}`;
thinkingLevel,
readOnly: true, // File description only reads, doesn't write
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
});
const description = result.text;

View File

@@ -19,7 +19,11 @@ import { simpleQuery } from '../../../providers/simple-query-service.js';
import * as secureFs from '../../../lib/secure-fs.js';
import * as path from 'path';
import type { SettingsService } from '../../../services/settings-service.js';
import { getAutoLoadClaudeMdSetting } from '../../../lib/settings-helpers.js';
import {
getAutoLoadClaudeMdSetting,
getPromptCustomization,
getActiveClaudeApiProfile,
} from '../../../lib/settings-helpers.js';
const logger = createLogger('DescribeImage');
@@ -278,12 +282,17 @@ export function createDescribeImageHandler(
logger.info(`[${requestId}] Using model: ${model}`);
// Build the instruction text
const instructionText =
`Describe this image in 1-2 sentences suitable for use as context in an AI coding assistant. ` +
`Focus on what the image shows and its purpose (e.g., "UI mockup showing login form with email/password fields", ` +
`"Architecture diagram of microservices", "Screenshot of error message in terminal").\n\n` +
`Respond with ONLY the description text, no additional formatting, preamble, or explanation.`;
// Get customized prompts from settings
const prompts = await getPromptCustomization(settingsService, '[DescribeImage]');
// Get active Claude API profile for alternative endpoint configuration
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
settingsService,
'[DescribeImage]'
);
// Build the instruction text from centralized prompts
const instructionText = prompts.contextDescription.describeImagePrompt;
// Build prompt based on provider capability
// Some providers (like Cursor) may not support image content blocks
@@ -323,6 +332,8 @@ export function createDescribeImageHandler(
thinkingLevel,
readOnly: true, // Image description only reads, doesn't write
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
});
logger.info(`[${requestId}] simpleQuery completed in ${Date.now() - queryStart}ms`);

View File

@@ -12,7 +12,10 @@ import { resolveModelString } from '@automaker/model-resolver';
import { CLAUDE_MODEL_MAP, type ThinkingLevel } from '@automaker/types';
import { simpleQuery } from '../../../providers/simple-query-service.js';
import type { SettingsService } from '../../../services/settings-service.js';
import { getPromptCustomization } from '../../../lib/settings-helpers.js';
import {
getPromptCustomization,
getActiveClaudeApiProfile,
} from '../../../lib/settings-helpers.js';
import {
buildUserPrompt,
isValidEnhancementMode,
@@ -126,6 +129,12 @@ export function createEnhanceHandler(
logger.debug(`Using model: ${resolvedModel}`);
// Get active Claude API profile for alternative endpoint configuration
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
settingsService,
'[EnhancePrompt]'
);
// Use simpleQuery - provider abstraction handles routing to correct provider
// The system prompt is combined with user prompt since some providers
// don't have a separate system prompt concept
@@ -137,6 +146,8 @@ export function createEnhanceHandler(
allowedTools: [],
thinkingLevel,
readOnly: true, // Prompt enhancement only generates text, doesn't write files
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
});
const enhancedText = result.text;

View File

@@ -0,0 +1,19 @@
/**
* Common utilities for event history routes
*/
import { createLogger } from '@automaker/utils';
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
/** Logger instance for event history operations */
export const logger = createLogger('EventHistory');
/**
* Extract user-friendly error message from error objects
*/
export { getErrorMessageShared as getErrorMessage };
/**
* Log error with automatic logger binding
*/
export const logError = createLogError(logger);

View File

@@ -0,0 +1,68 @@
/**
* Event History routes - HTTP API for event history management
*
* Provides endpoints for:
* - Listing events with filtering
* - Getting individual event details
* - Deleting events
* - Clearing all events
* - Replaying events to test hooks
*
* Mounted at /api/event-history in the main server.
*/
import { Router } from 'express';
import type { EventHistoryService } from '../../services/event-history-service.js';
import type { SettingsService } from '../../services/settings-service.js';
import { validatePathParams } from '../../middleware/validate-paths.js';
import { createListHandler } from './routes/list.js';
import { createGetHandler } from './routes/get.js';
import { createDeleteHandler } from './routes/delete.js';
import { createClearHandler } from './routes/clear.js';
import { createReplayHandler } from './routes/replay.js';
/**
* Create event history router with all endpoints
*
* Endpoints:
* - POST /list - List events with optional filtering
* - POST /get - Get a single event by ID
* - POST /delete - Delete an event by ID
* - POST /clear - Clear all events for a project
* - POST /replay - Replay an event to trigger hooks
*
* @param eventHistoryService - Instance of EventHistoryService
* @param settingsService - Instance of SettingsService (for replay)
* @returns Express Router configured with all event history endpoints
*/
export function createEventHistoryRoutes(
eventHistoryService: EventHistoryService,
settingsService: SettingsService
): Router {
const router = Router();
// List events with filtering
router.post('/list', validatePathParams('projectPath'), createListHandler(eventHistoryService));
// Get single event
router.post('/get', validatePathParams('projectPath'), createGetHandler(eventHistoryService));
// Delete event
router.post(
'/delete',
validatePathParams('projectPath'),
createDeleteHandler(eventHistoryService)
);
// Clear all events
router.post('/clear', validatePathParams('projectPath'), createClearHandler(eventHistoryService));
// Replay event
router.post(
'/replay',
validatePathParams('projectPath'),
createReplayHandler(eventHistoryService, settingsService)
);
return router;
}

View File

@@ -0,0 +1,33 @@
/**
* POST /api/event-history/clear - Clear all events for a project
*
* Request body: { projectPath: string }
* Response: { success: true, cleared: number }
*/
import type { Request, Response } from 'express';
import type { EventHistoryService } from '../../../services/event-history-service.js';
import { getErrorMessage, logError } from '../common.js';
export function createClearHandler(eventHistoryService: EventHistoryService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath } = req.body as { projectPath: string };
if (!projectPath || typeof projectPath !== 'string') {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
const cleared = await eventHistoryService.clearEvents(projectPath);
res.json({
success: true,
cleared,
});
} catch (error) {
logError(error, 'Clear events failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,43 @@
/**
* POST /api/event-history/delete - Delete an event by ID
*
* Request body: { projectPath: string, eventId: string }
* Response: { success: true } or { success: false, error: string }
*/
import type { Request, Response } from 'express';
import type { EventHistoryService } from '../../../services/event-history-service.js';
import { getErrorMessage, logError } from '../common.js';
export function createDeleteHandler(eventHistoryService: EventHistoryService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, eventId } = req.body as {
projectPath: string;
eventId: string;
};
if (!projectPath || typeof projectPath !== 'string') {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
if (!eventId || typeof eventId !== 'string') {
res.status(400).json({ success: false, error: 'eventId is required' });
return;
}
const deleted = await eventHistoryService.deleteEvent(projectPath, eventId);
if (!deleted) {
res.status(404).json({ success: false, error: 'Event not found' });
return;
}
res.json({ success: true });
} catch (error) {
logError(error, 'Delete event failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,46 @@
/**
* POST /api/event-history/get - Get a single event by ID
*
* Request body: { projectPath: string, eventId: string }
* Response: { success: true, event: StoredEvent } or { success: false, error: string }
*/
import type { Request, Response } from 'express';
import type { EventHistoryService } from '../../../services/event-history-service.js';
import { getErrorMessage, logError } from '../common.js';
export function createGetHandler(eventHistoryService: EventHistoryService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, eventId } = req.body as {
projectPath: string;
eventId: string;
};
if (!projectPath || typeof projectPath !== 'string') {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
if (!eventId || typeof eventId !== 'string') {
res.status(400).json({ success: false, error: 'eventId is required' });
return;
}
const event = await eventHistoryService.getEvent(projectPath, eventId);
if (!event) {
res.status(404).json({ success: false, error: 'Event not found' });
return;
}
res.json({
success: true,
event,
});
} catch (error) {
logError(error, 'Get event failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,53 @@
/**
* POST /api/event-history/list - List events for a project
*
* Request body: {
* projectPath: string,
* filter?: {
* trigger?: EventHookTrigger,
* featureId?: string,
* since?: string,
* until?: string,
* limit?: number,
* offset?: number
* }
* }
* Response: { success: true, events: StoredEventSummary[], total: number }
*/
import type { Request, Response } from 'express';
import type { EventHistoryService } from '../../../services/event-history-service.js';
import type { EventHistoryFilter } from '@automaker/types';
import { getErrorMessage, logError } from '../common.js';
export function createListHandler(eventHistoryService: EventHistoryService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, filter } = req.body as {
projectPath: string;
filter?: EventHistoryFilter;
};
if (!projectPath || typeof projectPath !== 'string') {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
const events = await eventHistoryService.getEvents(projectPath, filter);
const total = await eventHistoryService.getEventCount(projectPath, {
...filter,
limit: undefined,
offset: undefined,
});
res.json({
success: true,
events,
total,
});
} catch (error) {
logError(error, 'List events failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,234 @@
/**
* POST /api/event-history/replay - Replay an event to trigger hooks
*
* Request body: {
* projectPath: string,
* eventId: string,
* hookIds?: string[] // Optional: specific hooks to run (if not provided, runs all enabled matching hooks)
* }
* Response: { success: true, result: EventReplayResult }
*/
import type { Request, Response } from 'express';
import type { EventHistoryService } from '../../../services/event-history-service.js';
import type { SettingsService } from '../../../services/settings-service.js';
import type { EventReplayResult, EventReplayHookResult, EventHook } from '@automaker/types';
import { exec } from 'child_process';
import { promisify } from 'util';
import { getErrorMessage, logError, logger } from '../common.js';
const execAsync = promisify(exec);
/** Default timeout for shell commands (30 seconds) */
const DEFAULT_SHELL_TIMEOUT = 30000;
/** Default timeout for HTTP requests (10 seconds) */
const DEFAULT_HTTP_TIMEOUT = 10000;
interface HookContext {
featureId?: string;
featureName?: string;
projectPath?: string;
projectName?: string;
error?: string;
errorType?: string;
timestamp: string;
eventType: string;
}
/**
* Substitute {{variable}} placeholders in a string
*/
function substituteVariables(template: string, context: HookContext): string {
return template.replace(/\{\{(\w+)\}\}/g, (match, variable) => {
const value = context[variable as keyof HookContext];
if (value === undefined || value === null) {
return '';
}
return String(value);
});
}
/**
* Execute a single hook and return the result
*/
async function executeHook(hook: EventHook, context: HookContext): Promise<EventReplayHookResult> {
const hookName = hook.name || hook.id;
const startTime = Date.now();
try {
if (hook.action.type === 'shell') {
const command = substituteVariables(hook.action.command, context);
const timeout = hook.action.timeout || DEFAULT_SHELL_TIMEOUT;
logger.info(`Replaying shell hook "${hookName}": ${command}`);
await execAsync(command, {
timeout,
maxBuffer: 1024 * 1024,
});
return {
hookId: hook.id,
hookName: hook.name,
success: true,
durationMs: Date.now() - startTime,
};
} else if (hook.action.type === 'http') {
const url = substituteVariables(hook.action.url, context);
const method = hook.action.method || 'POST';
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (hook.action.headers) {
for (const [key, value] of Object.entries(hook.action.headers)) {
headers[key] = substituteVariables(value, context);
}
}
let body: string | undefined;
if (hook.action.body) {
body = substituteVariables(hook.action.body, context);
} else if (method !== 'GET') {
body = JSON.stringify({
eventType: context.eventType,
timestamp: context.timestamp,
featureId: context.featureId,
projectPath: context.projectPath,
projectName: context.projectName,
error: context.error,
});
}
logger.info(`Replaying HTTP hook "${hookName}": ${method} ${url}`);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), DEFAULT_HTTP_TIMEOUT);
const response = await fetch(url, {
method,
headers,
body: method !== 'GET' ? body : undefined,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
return {
hookId: hook.id,
hookName: hook.name,
success: false,
error: `HTTP ${response.status}: ${response.statusText}`,
durationMs: Date.now() - startTime,
};
}
return {
hookId: hook.id,
hookName: hook.name,
success: true,
durationMs: Date.now() - startTime,
};
}
return {
hookId: hook.id,
hookName: hook.name,
success: false,
error: 'Unknown hook action type',
durationMs: Date.now() - startTime,
};
} catch (error) {
const errorMessage =
error instanceof Error
? error.name === 'AbortError'
? 'Request timed out'
: error.message
: String(error);
return {
hookId: hook.id,
hookName: hook.name,
success: false,
error: errorMessage,
durationMs: Date.now() - startTime,
};
}
}
export function createReplayHandler(
eventHistoryService: EventHistoryService,
settingsService: SettingsService
) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, eventId, hookIds } = req.body as {
projectPath: string;
eventId: string;
hookIds?: string[];
};
if (!projectPath || typeof projectPath !== 'string') {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
if (!eventId || typeof eventId !== 'string') {
res.status(400).json({ success: false, error: 'eventId is required' });
return;
}
// Get the event
const event = await eventHistoryService.getEvent(projectPath, eventId);
if (!event) {
res.status(404).json({ success: false, error: 'Event not found' });
return;
}
// Get hooks from settings
const settings = await settingsService.getGlobalSettings();
let hooks = settings.eventHooks || [];
// Filter to matching trigger and enabled hooks
hooks = hooks.filter((h) => h.enabled && h.trigger === event.trigger);
// If specific hook IDs requested, filter to those
if (hookIds && hookIds.length > 0) {
hooks = hooks.filter((h) => hookIds.includes(h.id));
}
// Build context for variable substitution
const context: HookContext = {
featureId: event.featureId,
featureName: event.featureName,
projectPath: event.projectPath,
projectName: event.projectName,
error: event.error,
errorType: event.errorType,
timestamp: event.timestamp,
eventType: event.trigger,
};
// Execute all hooks in parallel
const hookResults = await Promise.all(hooks.map((hook) => executeHook(hook, context)));
const result: EventReplayResult = {
eventId,
hooksTriggered: hooks.length,
hookResults,
};
logger.info(`Replayed event ${eventId}: ${hooks.length} hooks triggered`);
res.json({
success: true,
result,
});
} catch (error) {
logError(error, 'Replay event failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -4,6 +4,8 @@
import { Router } from 'express';
import { FeatureLoader } from '../../services/feature-loader.js';
import type { SettingsService } from '../../services/settings-service.js';
import type { EventEmitter } from '../../lib/events.js';
import { validatePathParams } from '../../middleware/validate-paths.js';
import { createListHandler } from './routes/list.js';
import { createGetHandler } from './routes/get.js';
@@ -15,12 +17,20 @@ import { createDeleteHandler } from './routes/delete.js';
import { createAgentOutputHandler, createRawOutputHandler } from './routes/agent-output.js';
import { createGenerateTitleHandler } from './routes/generate-title.js';
export function createFeaturesRoutes(featureLoader: FeatureLoader): Router {
export function createFeaturesRoutes(
featureLoader: FeatureLoader,
settingsService?: SettingsService,
events?: EventEmitter
): Router {
const router = Router();
router.post('/list', validatePathParams('projectPath'), createListHandler(featureLoader));
router.post('/get', validatePathParams('projectPath'), createGetHandler(featureLoader));
router.post('/create', validatePathParams('projectPath'), createCreateHandler(featureLoader));
router.post(
'/create',
validatePathParams('projectPath'),
createCreateHandler(featureLoader, events)
);
router.post('/update', validatePathParams('projectPath'), createUpdateHandler(featureLoader));
router.post(
'/bulk-update',
@@ -35,7 +45,7 @@ export function createFeaturesRoutes(featureLoader: FeatureLoader): Router {
router.post('/delete', validatePathParams('projectPath'), createDeleteHandler(featureLoader));
router.post('/agent-output', createAgentOutputHandler(featureLoader));
router.post('/raw-output', createRawOutputHandler(featureLoader));
router.post('/generate-title', createGenerateTitleHandler());
router.post('/generate-title', createGenerateTitleHandler(settingsService));
return router;
}

View File

@@ -30,19 +30,27 @@ export function createBulkDeleteHandler(featureLoader: FeatureLoader) {
return;
}
const results = await Promise.all(
featureIds.map(async (featureId) => {
const success = await featureLoader.delete(projectPath, featureId);
if (success) {
return { featureId, success: true };
}
return {
featureId,
success: false,
error: 'Deletion failed. Check server logs for details.',
};
})
);
// Process in parallel batches of 20 for efficiency
const BATCH_SIZE = 20;
const results: BulkDeleteResult[] = [];
for (let i = 0; i < featureIds.length; i += BATCH_SIZE) {
const batch = featureIds.slice(i, i + BATCH_SIZE);
const batchResults = await Promise.all(
batch.map(async (featureId) => {
const success = await featureLoader.delete(projectPath, featureId);
if (success) {
return { featureId, success: true };
}
return {
featureId,
success: false,
error: 'Deletion failed. Check server logs for details.',
};
})
);
results.push(...batchResults);
}
const successCount = results.reduce((count, r) => count + (r.success ? 1 : 0), 0);
const failureCount = results.length - successCount;

View File

@@ -43,17 +43,36 @@ export function createBulkUpdateHandler(featureLoader: FeatureLoader) {
const results: BulkUpdateResult[] = [];
const updatedFeatures: Feature[] = [];
for (const featureId of featureIds) {
try {
const updated = await featureLoader.update(projectPath, featureId, updates);
results.push({ featureId, success: true });
updatedFeatures.push(updated);
} catch (error) {
results.push({
featureId,
success: false,
error: getErrorMessage(error),
});
// Process in parallel batches of 20 for efficiency
const BATCH_SIZE = 20;
for (let i = 0; i < featureIds.length; i += BATCH_SIZE) {
const batch = featureIds.slice(i, i + BATCH_SIZE);
const batchResults = await Promise.all(
batch.map(async (featureId) => {
try {
const updated = await featureLoader.update(projectPath, featureId, updates);
return { featureId, success: true as const, feature: updated };
} catch (error) {
return {
featureId,
success: false as const,
error: getErrorMessage(error),
};
}
})
);
for (const result of batchResults) {
if (result.success) {
results.push({ featureId: result.featureId, success: true });
updatedFeatures.push(result.feature);
} else {
results.push({
featureId: result.featureId,
success: false,
error: result.error,
});
}
}
}

View File

@@ -4,10 +4,11 @@
import type { Request, Response } from 'express';
import { FeatureLoader } from '../../../services/feature-loader.js';
import type { EventEmitter } from '../../../lib/events.js';
import type { Feature } from '@automaker/types';
import { getErrorMessage, logError } from '../common.js';
export function createCreateHandler(featureLoader: FeatureLoader) {
export function createCreateHandler(featureLoader: FeatureLoader, events?: EventEmitter) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, feature } = req.body as {
@@ -23,7 +24,30 @@ export function createCreateHandler(featureLoader: FeatureLoader) {
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
if (events) {
events.emit('feature:created', {
featureId: created.id,
featureName: created.name,
projectPath,
});
}
res.json({ success: true, feature: created });
} catch (error) {
logError(error, 'Create feature failed');

View File

@@ -9,6 +9,11 @@ import type { Request, Response } from 'express';
import { createLogger } from '@automaker/utils';
import { CLAUDE_MODEL_MAP } from '@automaker/model-resolver';
import { simpleQuery } from '../../../providers/simple-query-service.js';
import type { SettingsService } from '../../../services/settings-service.js';
import {
getPromptCustomization,
getActiveClaudeApiProfile,
} from '../../../lib/settings-helpers.js';
const logger = createLogger('GenerateTitle');
@@ -26,16 +31,9 @@ interface GenerateTitleErrorResponse {
error: string;
}
const SYSTEM_PROMPT = `You are a title generator. Your task is to create a concise, descriptive title (5-10 words max) for a software feature based on its description.
Rules:
- Output ONLY the title, nothing else
- Keep it short and action-oriented (e.g., "Add dark mode toggle", "Fix login validation")
- Start with a verb when possible (Add, Fix, Update, Implement, Create, etc.)
- No quotes, periods, or extra formatting
- Capture the essence of the feature in a scannable way`;
export function createGenerateTitleHandler(): (req: Request, res: Response) => Promise<void> {
export function createGenerateTitleHandler(
settingsService?: SettingsService
): (req: Request, res: Response) => Promise<void> {
return async (req: Request, res: Response): Promise<void> => {
try {
const { description } = req.body as GenerateTitleRequestBody;
@@ -61,15 +59,27 @@ export function createGenerateTitleHandler(): (req: Request, res: Response) => P
logger.info(`Generating title for description: ${trimmedDescription.substring(0, 50)}...`);
// Get customized prompts from settings
const prompts = await getPromptCustomization(settingsService, '[GenerateTitle]');
const systemPrompt = prompts.titleGeneration.systemPrompt;
// Get active Claude API profile for alternative endpoint configuration
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
settingsService,
'[GenerateTitle]'
);
const userPrompt = `Generate a concise title for this feature:\n\n${trimmedDescription}`;
// Use simpleQuery - provider abstraction handles all the streaming/extraction
const result = await simpleQuery({
prompt: `${SYSTEM_PROMPT}\n\n${userPrompt}`,
prompt: `${systemPrompt}\n\n${userPrompt}`,
model: CLAUDE_MODEL_MAP.haiku,
cwd: process.cwd(),
maxTurns: 1,
allowedTools: [],
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
});
const title = result.text;

View File

@@ -4,8 +4,14 @@
import type { Request, Response } from 'express';
import { FeatureLoader } from '../../../services/feature-loader.js';
import type { Feature } from '@automaker/types';
import type { Feature, FeatureStatus } from '@automaker/types';
import { getErrorMessage, logError } from '../common.js';
import { createLogger } from '@automaker/utils';
const logger = createLogger('features/update');
// Statuses that should trigger syncing to app_spec.txt
const SYNC_TRIGGER_STATUSES: FeatureStatus[] = ['verified', 'completed'];
export function createUpdateHandler(featureLoader: FeatureLoader) {
return async (req: Request, res: Response): Promise<void> => {
@@ -34,6 +40,28 @@ 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;
const newStatus = updates.status as FeatureStatus | undefined;
const updated = await featureLoader.update(
projectPath,
featureId,
@@ -42,6 +70,22 @@ export function createUpdateHandler(featureLoader: FeatureLoader) {
enhancementMode,
preEnhancementDescription
);
// Trigger sync to app_spec.txt when status changes to verified or completed
if (newStatus && SYNC_TRIGGER_STATUSES.includes(newStatus) && previousStatus !== newStatus) {
try {
const synced = await featureLoader.syncFeatureToAppSpec(projectPath, updated);
if (synced) {
logger.info(
`Synced feature "${updated.title || updated.id}" to app_spec.txt on status change to ${newStatus}`
);
}
} catch (syncError) {
// Log the sync error but don't fail the update operation
logger.error(`Failed to sync feature to app_spec.txt:`, syncError);
}
}
res.json({ success: true, feature: updated });
} catch (error) {
logError(error, 'Update feature failed');

View File

@@ -1,5 +1,12 @@
/**
* GET /image endpoint - Serve image files
*
* Requires authentication via auth middleware:
* - apiKey query parameter (Electron mode)
* - token query parameter (web mode)
* - session cookie (web mode)
* - X-API-Key header (Electron mode)
* - X-Session-Token header (web mode)
*/
import type { Request, Response } from 'express';

View File

@@ -30,11 +30,15 @@ import { writeValidation } from '../../../lib/validation-storage.js';
import { streamingQuery } from '../../../providers/simple-query-service.js';
import {
issueValidationSchema,
ISSUE_VALIDATION_SYSTEM_PROMPT,
buildValidationPrompt,
ValidationComment,
ValidationLinkedPR,
} from './validation-schema.js';
import {
getPromptCustomization,
getAutoLoadClaudeMdSetting,
getActiveClaudeApiProfile,
} from '../../../lib/settings-helpers.js';
import {
trySetValidationRunning,
clearValidationStatus,
@@ -43,7 +47,6 @@ import {
logger,
} from './validation-common.js';
import type { SettingsService } from '../../../services/settings-service.js';
import { getAutoLoadClaudeMdSetting } from '../../../lib/settings-helpers.js';
/**
* Request body for issue validation
@@ -117,13 +120,17 @@ async function runValidation(
let responseText = '';
// Get customized prompts from settings
const prompts = await getPromptCustomization(settingsService, '[ValidateIssue]');
const issueValidationSystemPrompt = prompts.issueValidation.systemPrompt;
// Determine if we should use structured output (Claude/Codex support it, Cursor/OpenCode don't)
const useStructuredOutput = isClaudeModel(model) || isCodexModel(model);
// Build the final prompt - for Cursor, include system prompt and JSON schema instructions
let finalPrompt = basePrompt;
if (!useStructuredOutput) {
finalPrompt = `${ISSUE_VALIDATION_SYSTEM_PROMPT}
finalPrompt = `${issueValidationSystemPrompt}
CRITICAL INSTRUCTIONS:
1. DO NOT write any files. Return the JSON in your response only.
@@ -162,17 +169,25 @@ ${basePrompt}`;
logger.info(`Using model: ${model}`);
// Get active Claude API profile for alternative endpoint configuration
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
settingsService,
'[IssueValidation]'
);
// Use streamingQuery with event callbacks
const result = await streamingQuery({
prompt: finalPrompt,
model: model as string,
cwd: projectPath,
systemPrompt: useStructuredOutput ? ISSUE_VALIDATION_SYSTEM_PROMPT : undefined,
systemPrompt: useStructuredOutput ? issueValidationSystemPrompt : undefined,
abortController,
thinkingLevel: effectiveThinkingLevel,
reasoningEffort: effectiveReasoningEffort,
readOnly: true, // Issue validation only reads code, doesn't write
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
outputFormat: useStructuredOutput
? {
type: 'json_schema',

View File

@@ -1,8 +1,11 @@
/**
* Issue Validation Schema and System Prompt
* Issue Validation Schema and Prompt Building
*
* Defines the JSON schema for Claude's structured output and
* the system prompt that guides the validation process.
* helper functions for building validation prompts.
*
* Note: The system prompt is now centralized in @automaker/prompts
* and accessed via getPromptCustomization() in validate-issue.ts
*/
/**
@@ -82,76 +85,6 @@ export const issueValidationSchema = {
additionalProperties: false,
} as const;
/**
* System prompt that guides Claude in validating GitHub issues.
* Instructs the model to use read-only tools to analyze the codebase.
*/
export const ISSUE_VALIDATION_SYSTEM_PROMPT = `You are an expert code analyst validating GitHub issues against a codebase.
Your task is to analyze a GitHub issue and determine if it's valid by scanning the codebase.
## Validation Process
1. **Read the issue carefully** - Understand what is being reported or requested
2. **Search the codebase** - Use Glob to find relevant files by pattern, Grep to search for keywords
3. **Examine the code** - Use Read to look at the actual implementation in relevant files
4. **Check linked PRs** - If there are linked pull requests, use \`gh pr diff <PR_NUMBER>\` to review the changes
5. **Form your verdict** - Based on your analysis, determine if the issue is valid
## Verdicts
- **valid**: The issue describes a real problem that exists in the codebase, or a clear feature request that can be implemented. The referenced files/components exist and the issue is actionable.
- **invalid**: The issue describes behavior that doesn't exist, references non-existent files or components, is based on a misunderstanding of the code, or the described "bug" is actually expected behavior.
- **needs_clarification**: The issue lacks sufficient detail to verify. Specify what additional information is needed in the missingInfo field.
## For Bug Reports, Check:
- Do the referenced files/components exist?
- Does the code match what the issue describes?
- Is the described behavior actually a bug or expected?
- Can you locate the code that would cause the reported issue?
## For Feature Requests, Check:
- Does the feature already exist?
- Is the implementation location clear?
- Is the request technically feasible given the codebase structure?
## Analyzing Linked Pull Requests
When an issue has linked PRs (especially open ones), you MUST analyze them:
1. **Run \`gh pr diff <PR_NUMBER>\`** to see what changes the PR makes
2. **Run \`gh pr view <PR_NUMBER>\`** to see PR description and status
3. **Evaluate if the PR fixes the issue** - Does the diff address the reported problem?
4. **Provide a recommendation**:
- \`wait_for_merge\`: The PR appears to fix the issue correctly. No additional work needed - just wait for it to be merged.
- \`pr_needs_work\`: The PR attempts to fix the issue but is incomplete or has problems.
- \`no_pr\`: No relevant PR exists for this issue.
5. **Include prAnalysis in your response** with:
- hasOpenPR: true/false
- prFixesIssue: true/false (based on diff analysis)
- prNumber: the PR number you analyzed
- prSummary: brief description of what the PR changes
- recommendation: one of the above values
## Response Guidelines
- **Always include relatedFiles** when you find relevant code
- **Set bugConfirmed to true** only if you can definitively confirm a bug exists in the code
- **Provide a suggestedFix** when you have a clear idea of how to address the issue
- **Use missingInfo** when the verdict is needs_clarification to list what's needed
- **Include prAnalysis** when there are linked PRs - this is critical for avoiding duplicate work
- **Set estimatedComplexity** to help prioritize:
- trivial: Simple text changes, one-line fixes
- simple: Small changes to one file
- moderate: Changes to multiple files or moderate logic changes
- complex: Significant refactoring or new feature implementation
- very_complex: Major architectural changes or cross-cutting concerns
Be thorough in your analysis but focus on files that are directly relevant to the issue.`;
/**
* Comment data structure for validation prompt
*/

View File

@@ -9,12 +9,14 @@ import type { Request, Response } from 'express';
export interface EnvironmentResponse {
isContainerized: boolean;
skipSandboxWarning?: boolean;
}
export function createEnvironmentHandler() {
return (_req: Request, res: Response): void => {
res.json({
isContainerized: process.env.IS_CONTAINERIZED === 'true',
skipSandboxWarning: process.env.AUTOMAKER_SKIP_SANDBOX_WARNING === 'true',
} satisfies EnvironmentResponse);
};
}

View File

@@ -0,0 +1,21 @@
/**
* Common utilities for notification routes
*
* Provides logger and error handling utilities shared across all notification endpoints.
*/
import { createLogger } from '@automaker/utils';
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
/** Logger instance for notification-related operations */
export const logger = createLogger('Notifications');
/**
* Extract user-friendly error message from error objects
*/
export { getErrorMessageShared as getErrorMessage };
/**
* Log error with automatic logger binding
*/
export const logError = createLogError(logger);

View File

@@ -0,0 +1,62 @@
/**
* Notifications routes - HTTP API for project-level notifications
*
* Provides endpoints for:
* - Listing notifications
* - Getting unread count
* - Marking notifications as read
* - Dismissing notifications
*
* All endpoints use handler factories that receive the NotificationService instance.
* Mounted at /api/notifications in the main server.
*/
import { Router } from 'express';
import type { NotificationService } from '../../services/notification-service.js';
import { validatePathParams } from '../../middleware/validate-paths.js';
import { createListHandler } from './routes/list.js';
import { createUnreadCountHandler } from './routes/unread-count.js';
import { createMarkReadHandler } from './routes/mark-read.js';
import { createDismissHandler } from './routes/dismiss.js';
/**
* Create notifications router with all endpoints
*
* Endpoints:
* - POST /list - List all notifications for a project
* - POST /unread-count - Get unread notification count
* - POST /mark-read - Mark notification(s) as read
* - POST /dismiss - Dismiss notification(s)
*
* @param notificationService - Instance of NotificationService
* @returns Express Router configured with all notification endpoints
*/
export function createNotificationsRoutes(notificationService: NotificationService): Router {
const router = Router();
// List notifications
router.post('/list', validatePathParams('projectPath'), createListHandler(notificationService));
// Get unread count
router.post(
'/unread-count',
validatePathParams('projectPath'),
createUnreadCountHandler(notificationService)
);
// Mark as read (single or all)
router.post(
'/mark-read',
validatePathParams('projectPath'),
createMarkReadHandler(notificationService)
);
// Dismiss (single or all)
router.post(
'/dismiss',
validatePathParams('projectPath'),
createDismissHandler(notificationService)
);
return router;
}

View File

@@ -0,0 +1,53 @@
/**
* POST /api/notifications/dismiss - Dismiss notification(s)
*
* Request body: { projectPath: string, notificationId?: string }
* - If notificationId provided: dismisses that notification
* - If notificationId not provided: dismisses all notifications
*
* Response: { success: true, dismissed: boolean | count: number }
*/
import type { Request, Response } from 'express';
import type { NotificationService } from '../../../services/notification-service.js';
import { getErrorMessage, logError } from '../common.js';
/**
* Create handler for POST /api/notifications/dismiss
*
* @param notificationService - Instance of NotificationService
* @returns Express request handler
*/
export function createDismissHandler(notificationService: NotificationService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, notificationId } = req.body;
if (!projectPath || typeof projectPath !== 'string') {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
// If notificationId provided, dismiss single notification
if (notificationId) {
const dismissed = await notificationService.dismissNotification(
projectPath,
notificationId
);
if (!dismissed) {
res.status(404).json({ success: false, error: 'Notification not found' });
return;
}
res.json({ success: true, dismissed: true });
return;
}
// Otherwise dismiss all
const count = await notificationService.dismissAll(projectPath);
res.json({ success: true, count });
} catch (error) {
logError(error, 'Dismiss failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,39 @@
/**
* POST /api/notifications/list - List all notifications for a project
*
* Request body: { projectPath: string }
* Response: { success: true, notifications: Notification[] }
*/
import type { Request, Response } from 'express';
import type { NotificationService } from '../../../services/notification-service.js';
import { getErrorMessage, logError } from '../common.js';
/**
* Create handler for POST /api/notifications/list
*
* @param notificationService - Instance of NotificationService
* @returns Express request handler
*/
export function createListHandler(notificationService: NotificationService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath } = req.body;
if (!projectPath || typeof projectPath !== 'string') {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
const notifications = await notificationService.getNotifications(projectPath);
res.json({
success: true,
notifications,
});
} catch (error) {
logError(error, 'List notifications failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,50 @@
/**
* POST /api/notifications/mark-read - Mark notification(s) as read
*
* Request body: { projectPath: string, notificationId?: string }
* - If notificationId provided: marks that notification as read
* - If notificationId not provided: marks all notifications as read
*
* Response: { success: true, count?: number, notification?: Notification }
*/
import type { Request, Response } from 'express';
import type { NotificationService } from '../../../services/notification-service.js';
import { getErrorMessage, logError } from '../common.js';
/**
* Create handler for POST /api/notifications/mark-read
*
* @param notificationService - Instance of NotificationService
* @returns Express request handler
*/
export function createMarkReadHandler(notificationService: NotificationService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, notificationId } = req.body;
if (!projectPath || typeof projectPath !== 'string') {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
// If notificationId provided, mark single notification
if (notificationId) {
const notification = await notificationService.markAsRead(projectPath, notificationId);
if (!notification) {
res.status(404).json({ success: false, error: 'Notification not found' });
return;
}
res.json({ success: true, notification });
return;
}
// Otherwise mark all as read
const count = await notificationService.markAllAsRead(projectPath);
res.json({ success: true, count });
} catch (error) {
logError(error, 'Mark read failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,39 @@
/**
* POST /api/notifications/unread-count - Get unread notification count
*
* Request body: { projectPath: string }
* Response: { success: true, count: number }
*/
import type { Request, Response } from 'express';
import type { NotificationService } from '../../../services/notification-service.js';
import { getErrorMessage, logError } from '../common.js';
/**
* Create handler for POST /api/notifications/unread-count
*
* @param notificationService - Instance of NotificationService
* @returns Express request handler
*/
export function createUnreadCountHandler(notificationService: NotificationService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath } = req.body;
if (!projectPath || typeof projectPath !== 'string') {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
const count = await notificationService.getUnreadCount(projectPath);
res.json({
success: true,
count,
});
} catch (error) {
logError(error, 'Get unread count failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -4,12 +4,58 @@
import type { Request, Response } from 'express';
import type { AutoModeService } from '../../../services/auto-mode-service.js';
import { getBacklogPlanStatus, getRunningDetails } from '../../backlog-plan/common.js';
import { getAllRunningGenerations } from '../../app-spec/common.js';
import path from 'path';
import { getErrorMessage, logError } from '../common.js';
export function createIndexHandler(autoModeService: AutoModeService) {
return async (_req: Request, res: Response): Promise<void> => {
try {
const runningAgents = await autoModeService.getRunningAgents();
const runningAgents = [...(await autoModeService.getRunningAgents())];
const backlogPlanStatus = getBacklogPlanStatus();
const backlogPlanDetails = getRunningDetails();
if (backlogPlanStatus.isRunning && backlogPlanDetails) {
runningAgents.push({
featureId: `backlog-plan:${backlogPlanDetails.projectPath}`,
projectPath: backlogPlanDetails.projectPath,
projectName: path.basename(backlogPlanDetails.projectPath),
isAutoMode: false,
title: 'Backlog plan',
description: backlogPlanDetails.prompt,
});
}
// Add spec/feature generation tasks
const specGenerations = getAllRunningGenerations();
for (const generation of specGenerations) {
let title: string;
let description: string;
switch (generation.type) {
case 'feature_generation':
title = 'Generating features from spec';
description = 'Creating features from the project specification';
break;
case 'sync':
title = 'Syncing spec with code';
description = 'Updating spec from codebase and completed features';
break;
default:
title = 'Regenerating spec';
description = 'Analyzing project and generating specification';
}
runningAgents.push({
featureId: `spec-generation:${generation.projectPath}`,
projectPath: generation.projectPath,
projectName: path.basename(generation.projectPath),
isAutoMode: false,
title,
description,
});
}
res.json({
success: true,

View File

@@ -12,6 +12,18 @@ import type { Request, Response } from 'express';
import type { SettingsService } from '../../../services/settings-service.js';
import type { GlobalSettings } from '../../../types/settings.js';
import { getErrorMessage, logError, logger } from '../common.js';
import { setLogLevel, LogLevel } from '@automaker/utils';
import { setRequestLoggingEnabled } from '../../../index.js';
/**
* Map server log level string to LogLevel enum
*/
const LOG_LEVEL_MAP: Record<string, LogLevel> = {
error: LogLevel.ERROR,
warn: LogLevel.WARN,
info: LogLevel.INFO,
debug: LogLevel.DEBUG,
};
/**
* Create handler factory for PUT /api/settings/global
@@ -33,18 +45,41 @@ export function createUpdateGlobalHandler(settingsService: SettingsService) {
}
// Minimal debug logging to help diagnose accidental wipes.
if ('projects' in updates || 'theme' in updates || 'localStorageMigrated' in updates) {
const projectsLen = Array.isArray((updates as any).projects)
? (updates as any).projects.length
: undefined;
logger.info(
`Update global settings request: projects=${projectsLen ?? 'n/a'}, theme=${
(updates as any).theme ?? 'n/a'
}, localStorageMigrated=${(updates as any).localStorageMigrated ?? 'n/a'}`
);
const projectsLen = Array.isArray((updates as any).projects)
? (updates as any).projects.length
: undefined;
const trashedLen = Array.isArray((updates as any).trashedProjects)
? (updates as any).trashedProjects.length
: undefined;
logger.info(
`[SERVER_SETTINGS_UPDATE] Request received: projects=${projectsLen ?? 'n/a'}, trashedProjects=${trashedLen ?? 'n/a'}, theme=${
(updates as any).theme ?? 'n/a'
}, localStorageMigrated=${(updates as any).localStorageMigrated ?? 'n/a'}`
);
logger.info('[SERVER_SETTINGS_UPDATE] Calling updateGlobalSettings...');
const settings = await settingsService.updateGlobalSettings(updates);
logger.info(
'[SERVER_SETTINGS_UPDATE] Update complete, projects count:',
settings.projects?.length ?? 0
);
// Apply server log level if it was updated
if ('serverLogLevel' in updates && updates.serverLogLevel) {
const level = LOG_LEVEL_MAP[updates.serverLogLevel];
if (level !== undefined) {
setLogLevel(level);
logger.info(`Server log level changed to: ${updates.serverLogLevel}`);
}
}
const settings = await settingsService.updateGlobalSettings(updates);
// Apply request logging setting if it was updated
if ('enableRequestLogging' in updates && typeof updates.enableRequestLogging === 'boolean') {
setRequestLoggingEnabled(updates.enableRequestLogging);
logger.info(
`HTTP request logging ${updates.enableRequestLogging ? 'enabled' : 'disabled'}`
);
}
res.json({
success: true,

View File

@@ -15,7 +15,11 @@ import { FeatureLoader } from '../../services/feature-loader.js';
import { getAppSpecPath } from '@automaker/platform';
import * as secureFs from '../../lib/secure-fs.js';
import type { SettingsService } from '../../services/settings-service.js';
import { getAutoLoadClaudeMdSetting } from '../../lib/settings-helpers.js';
import {
getAutoLoadClaudeMdSetting,
getPromptCustomization,
getActiveClaudeApiProfile,
} from '../../lib/settings-helpers.js';
const logger = createLogger('Suggestions');
@@ -137,11 +141,15 @@ export async function generateSuggestions(
modelOverride?: string,
thinkingLevelOverride?: ThinkingLevel
): Promise<void> {
// Get customized prompts from settings
const prompts = await getPromptCustomization(settingsService, '[Suggestions]');
// Map suggestion types to their prompts
const typePrompts: Record<string, string> = {
features: 'Analyze this project and suggest new features that would add value.',
refactoring: 'Analyze this project and identify refactoring opportunities.',
security: 'Analyze this project for security vulnerabilities and suggest fixes.',
performance: 'Analyze this project for performance issues and suggest optimizations.',
features: prompts.suggestions.featuresPrompt,
refactoring: prompts.suggestions.refactoringPrompt,
security: prompts.suggestions.securityPrompt,
performance: prompts.suggestions.performancePrompt,
};
// Load existing context to avoid duplicates
@@ -151,15 +159,7 @@ export async function generateSuggestions(
${existingContext}
${existingContext ? '\nIMPORTANT: Do NOT suggest features that are already implemented or already in the backlog above. Focus on NEW ideas that complement what already exists.\n' : ''}
Look at the codebase and provide 3-5 concrete suggestions.
For each suggestion, provide:
1. A category (e.g., "User Experience", "Security", "Performance")
2. A clear description of what to implement
3. Priority (1=high, 2=medium, 3=low)
4. Brief reasoning for why this would help
The response will be automatically formatted as structured JSON.`;
${prompts.suggestions.baseTemplate}`;
// Don't send initial message - let the agent output speak for itself
// The first agent message will be captured as an info entry
@@ -196,6 +196,12 @@ The response will be automatically formatted as structured JSON.`;
logger.info('[Suggestions] Using model:', model);
// Get active Claude API profile for alternative endpoint configuration
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
settingsService,
'[Suggestions]'
);
let responseText = '';
// Determine if we should use structured output (Claude supports it, Cursor doesn't)
@@ -227,6 +233,8 @@ Your entire response should be valid JSON starting with { and ending with }. No
thinkingLevel,
readOnly: true, // Suggestions only reads code, doesn't write
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
outputFormat: useStructuredOutput
? {
type: 'json_schema',

View File

@@ -29,6 +29,13 @@ import {
createGetAvailableEditorsHandler,
createRefreshEditorsHandler,
} from './routes/open-in-editor.js';
import {
createOpenInTerminalHandler,
createGetAvailableTerminalsHandler,
createGetDefaultTerminalHandler,
createRefreshTerminalsHandler,
createOpenInExternalTerminalHandler,
} from './routes/open-in-terminal.js';
import { createInitGitHandler } from './routes/init-git.js';
import { createMigrateHandler } from './routes/migrate.js';
import { createStartDevHandler } from './routes/start-dev.js';
@@ -97,9 +104,25 @@ export function createWorktreeRoutes(
);
router.post('/switch-branch', requireValidWorktree, createSwitchBranchHandler());
router.post('/open-in-editor', validatePathParams('worktreePath'), createOpenInEditorHandler());
router.post(
'/open-in-terminal',
validatePathParams('worktreePath'),
createOpenInTerminalHandler()
);
router.get('/default-editor', createGetDefaultEditorHandler());
router.get('/available-editors', createGetAvailableEditorsHandler());
router.post('/refresh-editors', createRefreshEditorsHandler());
// External terminal routes
router.get('/available-terminals', createGetAvailableTerminalsHandler());
router.get('/default-terminal', createGetDefaultTerminalHandler());
router.post('/refresh-terminals', createRefreshTerminalsHandler());
router.post(
'/open-in-external-terminal',
validatePathParams('worktreePath'),
createOpenInExternalTerminalHandler()
);
router.post('/init-git', validatePathParams('projectPath'), createInitGitHandler());
router.post('/migrate', createMigrateHandler());
router.post(

View File

@@ -13,6 +13,7 @@ import {
} from '../common.js';
import { updateWorktreePRInfo } from '../../../lib/worktree-metadata.js';
import { createLogger } from '@automaker/utils';
import { validatePRState } from '@automaker/types';
const logger = createLogger('CreatePR');
@@ -268,11 +269,12 @@ export function createCreatePRHandler() {
prAlreadyExisted = true;
// Store the existing PR info in metadata
// GitHub CLI returns uppercase states: OPEN, MERGED, CLOSED
await updateWorktreePRInfo(effectiveProjectPath, branchName, {
number: existingPr.number,
url: existingPr.url,
title: existingPr.title || title,
state: existingPr.state || 'open',
state: validatePRState(existingPr.state),
createdAt: new Date().toISOString(),
});
logger.debug(
@@ -319,11 +321,12 @@ export function createCreatePRHandler() {
if (prNumber) {
try {
// Note: GitHub doesn't have a 'DRAFT' state - drafts still show as 'OPEN'
await updateWorktreePRInfo(effectiveProjectPath, branchName, {
number: prNumber,
url: prUrl,
title,
state: draft ? 'draft' : 'open',
state: 'OPEN',
createdAt: new Date().toISOString(),
});
logger.debug(`Stored PR info for branch ${branchName}: PR #${prNumber}`);
@@ -352,11 +355,12 @@ export function createCreatePRHandler() {
prNumber = existingPr.number;
prAlreadyExisted = true;
// GitHub CLI returns uppercase states: OPEN, MERGED, CLOSED
await updateWorktreePRInfo(effectiveProjectPath, branchName, {
number: existingPr.number,
url: existingPr.url,
title: existingPr.title || title,
state: existingPr.state || 'open',
state: validatePRState(existingPr.state),
createdAt: new Date().toISOString(),
});
logger.debug(`Fetched and stored existing PR: #${existingPr.number}`);

View File

@@ -34,6 +34,7 @@ export function createGetDevServerLogsHandler() {
result: {
worktreePath: result.result.worktreePath,
port: result.result.port,
url: result.result.url,
logs: result.result.logs,
startedAt: result.result.startedAt,
},

View File

@@ -10,7 +10,6 @@ import { exec } from 'child_process';
import { promisify } from 'util';
import { existsSync } from 'fs';
import { join } from 'path';
import { query } from '@anthropic-ai/claude-agent-sdk';
import { createLogger } from '@automaker/utils';
import { DEFAULT_PHASE_MODELS, isCursorModel, stripProviderPrefix } from '@automaker/types';
import { resolvePhaseModel } from '@automaker/model-resolver';
@@ -18,6 +17,7 @@ import { mergeCommitMessagePrompts } from '@automaker/prompts';
import { ProviderFactory } from '../../../providers/provider-factory.js';
import type { SettingsService } from '../../../services/settings-service.js';
import { getErrorMessage, logError } from '../common.js';
import { getActiveClaudeApiProfile } from '../../../lib/settings-helpers.js';
const logger = createLogger('GenerateCommitMessage');
const execAsync = promisify(exec);
@@ -74,33 +74,6 @@ interface GenerateCommitMessageErrorResponse {
error: string;
}
async function extractTextFromStream(
stream: AsyncIterable<{
type: string;
subtype?: string;
result?: string;
message?: {
content?: Array<{ type: string; text?: string }>;
};
}>
): Promise<string> {
let responseText = '';
for await (const msg of stream) {
if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text' && block.text) {
responseText += block.text;
}
}
} else if (msg.type === 'result' && msg.subtype === 'success') {
responseText = msg.result || responseText;
}
}
return responseText;
}
export function createGenerateCommitMessageHandler(
settingsService?: SettingsService
): (req: Request, res: Response) => Promise<void> {
@@ -195,57 +168,53 @@ export function createGenerateCommitMessageHandler(
// Get the effective system prompt (custom or default)
const systemPrompt = await getSystemPrompt(settingsService);
let message: string;
// Get active Claude API profile for alternative endpoint configuration
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
settingsService,
'[GenerateCommitMessage]'
);
// Route to appropriate provider based on model type
if (isCursorModel(model)) {
// Use Cursor provider for Cursor models
logger.info(`Using Cursor provider for model: ${model}`);
// Get provider for the model type
const provider = ProviderFactory.getProviderForModel(model);
const bareModel = stripProviderPrefix(model);
const provider = ProviderFactory.getProviderForModel(model);
const bareModel = stripProviderPrefix(model);
// For Cursor models, combine prompts since Cursor doesn't support systemPrompt separation
const effectivePrompt = isCursorModel(model)
? `${systemPrompt}\n\n${userPrompt}`
: userPrompt;
const effectiveSystemPrompt = isCursorModel(model) ? undefined : systemPrompt;
const cursorPrompt = `${systemPrompt}\n\n${userPrompt}`;
logger.info(`Using ${provider.getName()} provider for model: ${model}`);
let responseText = '';
const cursorStream = provider.executeQuery({
prompt: cursorPrompt,
model: bareModel,
cwd: worktreePath,
maxTurns: 1,
allowedTools: [],
readOnly: true,
});
let responseText = '';
const stream = provider.executeQuery({
prompt: effectivePrompt,
model: bareModel,
cwd: worktreePath,
systemPrompt: effectiveSystemPrompt,
maxTurns: 1,
allowedTools: [],
readOnly: true,
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
});
// Wrap with timeout to prevent indefinite hangs
for await (const msg of withTimeout(cursorStream, AI_TIMEOUT_MS)) {
if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text' && block.text) {
responseText += block.text;
}
// Wrap with timeout to prevent indefinite hangs
for await (const msg of withTimeout(stream, AI_TIMEOUT_MS)) {
if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text' && block.text) {
responseText += block.text;
}
}
} else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) {
// Use result if available (some providers return final text here)
responseText = msg.result;
}
message = responseText.trim();
} else {
// Use Claude SDK for Claude models
const stream = query({
prompt: userPrompt,
options: {
model,
systemPrompt,
maxTurns: 1,
allowedTools: [],
permissionMode: 'default',
},
});
// Wrap with timeout to prevent indefinite hangs
message = await extractTextFromStream(withTimeout(stream, AI_TIMEOUT_MS));
}
const message = responseText.trim();
if (!message || message.trim().length === 0) {
logger.warn('Received empty response from model');
const response: GenerateCommitMessageErrorResponse = {

View File

@@ -14,12 +14,34 @@ import path from 'path';
import * as secureFs from '../../../lib/secure-fs.js';
import { isGitRepo } from '@automaker/git-utils';
import { getErrorMessage, logError, normalizePath, execEnv, isGhCliAvailable } from '../common.js';
import { readAllWorktreeMetadata, type WorktreePRInfo } from '../../../lib/worktree-metadata.js';
import {
readAllWorktreeMetadata,
updateWorktreePRInfo,
type WorktreePRInfo,
} from '../../../lib/worktree-metadata.js';
import { createLogger } from '@automaker/utils';
import { validatePRState } from '@automaker/types';
import {
checkGitHubRemote,
type GitHubRemoteStatus,
} from '../../github/routes/check-github-remote.js';
const execAsync = promisify(exec);
const logger = createLogger('Worktree');
/**
* Cache for GitHub remote status per project path.
* This prevents repeated "no git remotes found" warnings when polling
* projects that don't have a GitHub remote configured.
*/
interface GitHubRemoteCacheEntry {
status: GitHubRemoteStatus;
checkedAt: number;
}
const githubRemoteCache = new Map<string, GitHubRemoteCacheEntry>();
const GITHUB_REMOTE_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
interface WorktreeInfo {
path: string;
branch: string;
@@ -122,22 +144,65 @@ async function scanWorktreesDirectory(
}
/**
* Fetch open PRs from GitHub and create a map of branch name to PR info.
* This allows detecting PRs that were created outside the app.
* Get cached GitHub remote status for a project, or check and cache it.
* Returns null if gh CLI is not available.
*/
async function getGitHubRemoteStatus(projectPath: string): Promise<GitHubRemoteStatus | null> {
// Check if gh CLI is available first
const ghAvailable = await isGhCliAvailable();
if (!ghAvailable) {
return null;
}
const now = Date.now();
const cached = githubRemoteCache.get(projectPath);
// Return cached result if still valid
if (cached && now - cached.checkedAt < GITHUB_REMOTE_CACHE_TTL_MS) {
return cached.status;
}
// Check GitHub remote and cache the result
const status = await checkGitHubRemote(projectPath);
githubRemoteCache.set(projectPath, {
status,
checkedAt: Date.now(),
});
return status;
}
/**
* Fetch all PRs from GitHub and create a map of branch name to PR info.
* Uses --state all to include merged/closed PRs, allowing detection of
* state changes (e.g., when a PR is merged on GitHub).
*
* This also allows detecting PRs that were created outside the app.
*
* Uses cached GitHub remote status to avoid repeated warnings when the
* project doesn't have a GitHub remote configured.
*/
async function fetchGitHubPRs(projectPath: string): Promise<Map<string, WorktreePRInfo>> {
const prMap = new Map<string, WorktreePRInfo>();
try {
// Check if gh CLI is available
const ghAvailable = await isGhCliAvailable();
if (!ghAvailable) {
// Check GitHub remote status (uses cache to avoid repeated warnings)
const remoteStatus = await getGitHubRemoteStatus(projectPath);
// If gh CLI not available or no GitHub remote, return empty silently
if (!remoteStatus || !remoteStatus.hasGitHubRemote) {
return prMap;
}
// Fetch open PRs from GitHub
// Use -R flag with owner/repo for more reliable PR fetching
const repoFlag =
remoteStatus.owner && remoteStatus.repo
? `-R ${remoteStatus.owner}/${remoteStatus.repo}`
: '';
// Fetch all PRs from GitHub (including merged/closed to detect state changes)
const { stdout } = await execAsync(
'gh pr list --state open --json number,title,url,state,headRefName,createdAt --limit 1000',
`gh pr list ${repoFlag} --state all --json number,title,url,state,headRefName,createdAt --limit 1000`,
{ cwd: projectPath, env: execEnv, timeout: 15000 }
);
@@ -155,7 +220,8 @@ async function fetchGitHubPRs(projectPath: string): Promise<Map<string, Worktree
number: pr.number,
url: pr.url,
title: pr.title,
state: pr.state,
// GitHub CLI returns state as uppercase: OPEN, MERGED, CLOSED
state: validatePRState(pr.state),
createdAt: pr.createdAt,
});
}
@@ -170,9 +236,10 @@ async function fetchGitHubPRs(projectPath: string): Promise<Map<string, Worktree
export function createListHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, includeDetails } = req.body as {
const { projectPath, includeDetails, forceRefreshGitHub } = req.body as {
projectPath: string;
includeDetails?: boolean;
forceRefreshGitHub?: boolean;
};
if (!projectPath) {
@@ -180,6 +247,12 @@ export function createListHandler() {
return;
}
// Clear GitHub remote cache if force refresh requested
// This allows users to re-check for GitHub remote after adding one
if (forceRefreshGitHub) {
githubRemoteCache.delete(projectPath);
}
if (!(await isGitRepo(projectPath))) {
res.json({ success: true, worktrees: [] });
return;
@@ -287,23 +360,43 @@ export function createListHandler() {
}
}
// Add PR info from metadata or GitHub for each worktree
// Only fetch GitHub PRs if includeDetails is requested (performance optimization)
// Assign PR info to each worktree, preferring fresh GitHub data over cached metadata.
// Only fetch GitHub PRs if includeDetails is requested (performance optimization).
// Uses --state all to detect merged/closed PRs, limited to 1000 recent PRs.
const githubPRs = includeDetails
? await fetchGitHubPRs(projectPath)
: new Map<string, WorktreePRInfo>();
for (const worktree of worktrees) {
// Skip PR assignment for the main worktree - it's not meaningful to show
// PRs on the main branch tab, and can be confusing if someone created
// a PR from main to another branch
if (worktree.isMain) {
continue;
}
const metadata = allMetadata.get(worktree.branch);
if (metadata?.pr) {
// Use stored metadata (more complete info)
worktree.pr = metadata.pr;
} else if (includeDetails) {
// Fall back to GitHub PR detection only when includeDetails is requested
const githubPR = githubPRs.get(worktree.branch);
if (githubPR) {
worktree.pr = githubPR;
const githubPR = githubPRs.get(worktree.branch);
if (githubPR) {
// Prefer fresh GitHub data (it has the current state)
worktree.pr = githubPR;
// Sync metadata with GitHub state when:
// 1. No metadata exists for this PR (PR created externally)
// 2. State has changed (e.g., merged/closed on GitHub)
const needsSync = !metadata?.pr || metadata.pr.state !== githubPR.state;
if (needsSync) {
// Fire and forget - don't block the response
updateWorktreePRInfo(projectPath, worktree.branch, githubPR).catch((err) => {
logger.warn(
`Failed to update PR info for ${worktree.branch}: ${getErrorMessage(err)}`
);
});
}
} else if (metadata?.pr && metadata.pr.state === 'OPEN') {
// Fall back to stored metadata only if the PR is still OPEN
worktree.pr = metadata.pr;
}
}

View File

@@ -0,0 +1,181 @@
/**
* Terminal endpoints for opening worktree directories in terminals
*
* POST /open-in-terminal - Open in system default terminal (integrated)
* GET /available-terminals - List all available external terminals
* GET /default-terminal - Get the default external terminal
* POST /refresh-terminals - Clear terminal cache and re-detect
* POST /open-in-external-terminal - Open a directory in an external terminal
*/
import type { Request, Response } from 'express';
import { isAbsolute } from 'path';
import {
openInTerminal,
clearTerminalCache,
detectAllTerminals,
detectDefaultTerminal,
openInExternalTerminal,
} from '@automaker/platform';
import { createLogger } from '@automaker/utils';
import { getErrorMessage, logError } from '../common.js';
const logger = createLogger('open-in-terminal');
/**
* Handler to open in system default terminal (integrated terminal behavior)
*/
export function createOpenInTerminalHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath } = req.body as {
worktreePath: string;
};
if (!worktreePath || typeof worktreePath !== 'string') {
res.status(400).json({
success: false,
error: 'worktreePath required and must be a string',
});
return;
}
// Security: Validate that worktreePath is an absolute path
if (!isAbsolute(worktreePath)) {
res.status(400).json({
success: false,
error: 'worktreePath must be an absolute path',
});
return;
}
// Use the platform utility to open in terminal
const result = await openInTerminal(worktreePath);
res.json({
success: true,
result: {
message: `Opened terminal in ${worktreePath}`,
terminalName: result.terminalName,
},
});
} catch (error) {
logError(error, 'Open in terminal failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}
/**
* Handler to get all available external terminals
*/
export function createGetAvailableTerminalsHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
const terminals = await detectAllTerminals();
res.json({
success: true,
result: {
terminals,
},
});
} catch (error) {
logError(error, 'Get available terminals failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}
/**
* Handler to get the default external terminal
*/
export function createGetDefaultTerminalHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
const terminal = await detectDefaultTerminal();
res.json({
success: true,
result: terminal
? {
terminalId: terminal.id,
terminalName: terminal.name,
terminalCommand: terminal.command,
}
: null,
});
} catch (error) {
logError(error, 'Get default terminal failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}
/**
* Handler to refresh the terminal cache and re-detect available terminals
* Useful when the user has installed/uninstalled terminals
*/
export function createRefreshTerminalsHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
// Clear the cache
clearTerminalCache();
// Re-detect terminals (this will repopulate the cache)
const terminals = await detectAllTerminals();
logger.info(`Terminal cache refreshed, found ${terminals.length} terminals`);
res.json({
success: true,
result: {
terminals,
message: `Found ${terminals.length} available external terminals`,
},
});
} catch (error) {
logError(error, 'Refresh terminals failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}
/**
* Handler to open a directory in an external terminal
*/
export function createOpenInExternalTerminalHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, terminalId } = req.body as {
worktreePath: string;
terminalId?: string;
};
if (!worktreePath || typeof worktreePath !== 'string') {
res.status(400).json({
success: false,
error: 'worktreePath required and must be a string',
});
return;
}
if (!isAbsolute(worktreePath)) {
res.status(400).json({
success: false,
error: 'worktreePath must be an absolute path',
});
return;
}
const result = await openInExternalTerminal(worktreePath, terminalId);
res.json({
success: true,
result: {
message: `Opened ${worktreePath} in ${result.terminalName}`,
terminalName: result.terminalName,
},
});
} catch (error) {
logError(error, 'Open in external terminal failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -29,6 +29,7 @@ import {
getSkillsConfiguration,
getSubagentsConfiguration,
getCustomSubagents,
getActiveClaudeApiProfile,
} from '../lib/settings-helpers.js';
interface Message {
@@ -274,6 +275,12 @@ export class AgentService {
? await getCustomSubagents(this.settingsService, effectiveWorkDir)
: undefined;
// Get active Claude API profile for alternative endpoint configuration
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
this.settingsService,
'[AgentService]'
);
// Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) and memory files
// Use the user's message as task context for smart memory selection
const contextResult = await loadContextFiles({
@@ -378,6 +385,8 @@ export class AgentService {
agents: customSubagents, // Pass custom subagents for task delegation
thinkingLevel: effectiveThinkingLevel, // Pass thinking level for Claude models
reasoningEffort: effectiveReasoningEffort, // Pass reasoning effort for Codex models
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
};
// Build prompt content with images

File diff suppressed because it is too large Load Diff

View File

@@ -22,6 +22,29 @@ export class ClaudeUsageService {
private timeout = 30000; // 30 second timeout
private isWindows = os.platform() === 'win32';
private isLinux = os.platform() === 'linux';
// On Windows, ConPTY requires AttachConsole which fails in Electron/service mode
// Detect Electron by checking for electron-specific env vars or process properties
// When in Electron, always use winpty to avoid ConPTY's AttachConsole errors
private isElectron =
!!(process.versions && (process.versions as Record<string, string>).electron) ||
!!process.env.ELECTRON_RUN_AS_NODE;
private useConptyFallback = false; // Track if we need to use winpty fallback on Windows
/**
* Kill a PTY process with platform-specific handling.
* Windows doesn't support Unix signals like SIGTERM, so we call kill() without arguments.
* On Unix-like systems (macOS, Linux), we can specify the signal.
*
* @param ptyProcess - The PTY process to kill
* @param signal - The signal to send on Unix-like systems (default: 'SIGTERM')
*/
private killPtyProcess(ptyProcess: pty.IPty, signal: string = 'SIGTERM'): void {
if (this.isWindows) {
ptyProcess.kill();
} else {
ptyProcess.kill(signal);
}
}
/**
* Check if Claude CLI is available on the system
@@ -181,37 +204,94 @@ export class ClaudeUsageService {
? ['/c', 'claude', '--add-dir', workingDirectory]
: ['-c', `claude --add-dir "${workingDirectory}"`];
// Using 'any' for ptyProcess because node-pty types don't include 'killed' property
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let ptyProcess: any = null;
// Build PTY spawn options
const ptyOptions: pty.IPtyForkOptions = {
name: 'xterm-256color',
cols: 120,
rows: 30,
cwd: workingDirectory,
env: {
...process.env,
TERM: 'xterm-256color',
} as Record<string, string>,
};
// On Windows, always use winpty instead of ConPTY
// ConPTY requires AttachConsole which fails in many contexts:
// - Electron apps without a console
// - VS Code integrated terminal
// - Spawned from other applications
// The error happens in a subprocess so we can't catch it - must proactively disable
if (this.isWindows) {
(ptyOptions as pty.IWindowsPtyForkOptions).useConpty = false;
logger.info(
'[executeClaudeUsageCommandPty] Using winpty on Windows (ConPTY disabled for compatibility)'
);
}
try {
ptyProcess = pty.spawn(shell, args, {
name: 'xterm-256color',
cols: 120,
rows: 30,
cwd: workingDirectory,
env: {
...process.env,
TERM: 'xterm-256color',
} as Record<string, string>,
});
ptyProcess = pty.spawn(shell, args, ptyOptions);
} catch (spawnError) {
const errorMessage = spawnError instanceof Error ? spawnError.message : String(spawnError);
logger.error('[executeClaudeUsageCommandPty] Failed to spawn PTY:', errorMessage);
// Return a user-friendly error instead of crashing
reject(
new Error(
`Unable to access terminal: ${errorMessage}. Claude CLI may not be available or PTY support is limited in this environment.`
)
);
return;
// Check for Windows ConPTY-specific errors
if (this.isWindows && errorMessage.includes('AttachConsole failed')) {
// ConPTY failed - try winpty fallback
if (!this.useConptyFallback) {
logger.warn(
'[executeClaudeUsageCommandPty] ConPTY AttachConsole failed, retrying with winpty fallback'
);
this.useConptyFallback = true;
try {
(ptyOptions as pty.IWindowsPtyForkOptions).useConpty = false;
ptyProcess = pty.spawn(shell, args, ptyOptions);
logger.info(
'[executeClaudeUsageCommandPty] Successfully spawned with winpty fallback'
);
} catch (fallbackError) {
const fallbackMessage =
fallbackError instanceof Error ? fallbackError.message : String(fallbackError);
logger.error(
'[executeClaudeUsageCommandPty] Winpty fallback also failed:',
fallbackMessage
);
reject(
new Error(
`Windows PTY unavailable: Both ConPTY and winpty failed. This typically happens when running in Electron without a console. ConPTY error: ${errorMessage}. Winpty error: ${fallbackMessage}`
)
);
return;
}
} else {
logger.error('[executeClaudeUsageCommandPty] Winpty fallback failed:', errorMessage);
reject(
new Error(
`Windows PTY unavailable: ${errorMessage}. The application is running without console access (common in Electron). Try running from a terminal window.`
)
);
return;
}
} else {
logger.error('[executeClaudeUsageCommandPty] Failed to spawn PTY:', errorMessage);
reject(
new Error(
`Unable to access terminal: ${errorMessage}. Claude CLI may not be available or PTY support is limited in this environment.`
)
);
return;
}
}
const timeoutId = setTimeout(() => {
if (!settled) {
settled = true;
if (ptyProcess && !ptyProcess.killed) {
ptyProcess.kill();
this.killPtyProcess(ptyProcess);
}
// Don't fail if we have data - return it instead
if (output.includes('Current session')) {
@@ -244,16 +324,23 @@ export class ClaudeUsageService {
const cleanOutput = output.replace(/\x1B\[[0-9;]*[A-Za-z]/g, '');
// Check for specific authentication/permission errors
if (
cleanOutput.includes('OAuth token does not meet scope requirement') ||
cleanOutput.includes('permission_error') ||
cleanOutput.includes('token_expired') ||
cleanOutput.includes('authentication_error')
) {
// Must be very specific to avoid false positives from garbled terminal encoding
// Removed permission_error check as it was causing false positives with winpty encoding
const authChecks = {
oauth: cleanOutput.includes('OAuth token does not meet scope requirement'),
tokenExpired: cleanOutput.includes('token_expired'),
// Only match if it looks like a JSON API error response
authError:
cleanOutput.includes('"type":"authentication_error"') ||
cleanOutput.includes('"type": "authentication_error"'),
};
const hasAuthError = authChecks.oauth || authChecks.tokenExpired || authChecks.authError;
if (hasAuthError) {
if (!settled) {
settled = true;
if (ptyProcess && !ptyProcess.killed) {
ptyProcess.kill();
this.killPtyProcess(ptyProcess);
}
reject(
new Error(
@@ -265,11 +352,16 @@ export class ClaudeUsageService {
}
// Check if we've seen the usage data (look for "Current session" or the TUI Usage header)
if (
!hasSeenUsageData &&
(cleanOutput.includes('Current session') ||
(cleanOutput.includes('Usage') && cleanOutput.includes('% left')))
) {
// Also check for percentage patterns that appear in usage output
const hasUsageIndicators =
cleanOutput.includes('Current session') ||
(cleanOutput.includes('Usage') && cleanOutput.includes('% left')) ||
// Additional patterns for winpty - look for percentage patterns
/\d+%\s*(left|used|remaining)/i.test(cleanOutput) ||
cleanOutput.includes('Resets in') ||
cleanOutput.includes('Current week');
if (!hasSeenUsageData && hasUsageIndicators) {
hasSeenUsageData = true;
// Wait for full output, then send escape to exit
setTimeout(() => {
@@ -277,9 +369,10 @@ export class ClaudeUsageService {
ptyProcess.write('\x1b'); // Send escape key
// Fallback: if ESC doesn't exit (Linux), use SIGTERM after 2s
// Windows doesn't support signals, so killPtyProcess handles platform differences
setTimeout(() => {
if (!settled && ptyProcess && !ptyProcess.killed) {
ptyProcess.kill('SIGTERM');
this.killPtyProcess(ptyProcess);
}
}, 2000);
}
@@ -307,10 +400,18 @@ export class ClaudeUsageService {
}
// Detect REPL prompt and send /usage command
if (
!hasSentCommand &&
(cleanOutput.includes('') || cleanOutput.includes('? for shortcuts'))
) {
// On Windows with winpty, Unicode prompt char gets garbled, so also check for ASCII indicators
const isReplReady =
cleanOutput.includes('') ||
cleanOutput.includes('? for shortcuts') ||
// Fallback for winpty garbled encoding - detect CLI welcome screen elements
(cleanOutput.includes('Welcome back') && cleanOutput.includes('Claude')) ||
(cleanOutput.includes('Tips for getting started') && cleanOutput.includes('Claude')) ||
// Detect model indicator which appears when REPL is ready
(cleanOutput.includes('Opus') && cleanOutput.includes('Claude API')) ||
(cleanOutput.includes('Sonnet') && cleanOutput.includes('Claude API'));
if (!hasSentCommand && isReplReady) {
hasSentCommand = true;
// Wait for REPL to fully settle
setTimeout(() => {
@@ -347,11 +448,9 @@ export class ClaudeUsageService {
if (settled) return;
settled = true;
if (
output.includes('token_expired') ||
output.includes('authentication_error') ||
output.includes('permission_error')
) {
// Check for auth errors - must be specific to avoid false positives
// Removed permission_error check as it was causing false positives with winpty encoding
if (output.includes('token_expired') || output.includes('"type":"authentication_error"')) {
reject(new Error("Authentication required - please run 'claude login'"));
return;
}

View File

@@ -379,10 +379,11 @@ class DevServerService {
// Create server info early so we can reference it in handlers
// We'll add it to runningServers after verifying the process started successfully
const hostname = process.env.HOSTNAME || 'localhost';
const serverInfo: DevServerInfo = {
worktreePath,
port,
url: `http://localhost:${port}`,
url: `http://${hostname}:${port}`,
process: devProcess,
startedAt: new Date(),
scrollbackBuffer: '',
@@ -474,7 +475,7 @@ class DevServerService {
result: {
worktreePath,
port,
url: `http://localhost:${port}`,
url: `http://${hostname}:${port}`,
message: `Dev server started on port ${port}`,
},
};
@@ -594,6 +595,7 @@ class DevServerService {
result?: {
worktreePath: string;
port: number;
url: string;
logs: string;
startedAt: string;
};
@@ -613,6 +615,7 @@ class DevServerService {
result: {
worktreePath: server.worktreePath,
port: server.port,
url: server.url,
logs: server.scrollbackBuffer,
startedAt: server.startedAt.toISOString(),
},

View File

@@ -0,0 +1,338 @@
/**
* Event History Service - Stores and retrieves event records for debugging and replay
*
* Provides persistent storage for events in {projectPath}/.automaker/events/
* Each event is stored as a separate JSON file with an index for quick listing.
*
* Features:
* - Store events when they occur
* - List and filter historical events
* - Replay events to test hook configurations
* - Delete old events to manage disk space
*/
import { createLogger } from '@automaker/utils';
import * as secureFs from '../lib/secure-fs.js';
import {
getEventHistoryDir,
getEventHistoryIndexPath,
getEventPath,
ensureEventHistoryDir,
} from '@automaker/platform';
import type {
StoredEvent,
StoredEventIndex,
StoredEventSummary,
EventHistoryFilter,
EventHookTrigger,
} from '@automaker/types';
import { DEFAULT_EVENT_HISTORY_INDEX } from '@automaker/types';
import { randomUUID } from 'crypto';
const logger = createLogger('EventHistoryService');
/** Maximum events to keep in the index (oldest are pruned) */
const MAX_EVENTS_IN_INDEX = 1000;
/**
* Atomic file write - write to temp file then rename
*/
async function atomicWriteJson(filePath: string, data: unknown): Promise<void> {
const tempPath = `${filePath}.tmp.${Date.now()}`;
const content = JSON.stringify(data, null, 2);
try {
await secureFs.writeFile(tempPath, content, 'utf-8');
await secureFs.rename(tempPath, filePath);
} catch (error) {
try {
await secureFs.unlink(tempPath);
} catch {
// Ignore cleanup errors
}
throw error;
}
}
/**
* Safely read JSON file with fallback to default
*/
async function readJsonFile<T>(filePath: string, defaultValue: T): Promise<T> {
try {
const content = (await secureFs.readFile(filePath, 'utf-8')) as string;
return JSON.parse(content) as T;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return defaultValue;
}
logger.error(`Error reading ${filePath}:`, error);
return defaultValue;
}
}
/**
* Input for storing a new event
*/
export interface StoreEventInput {
trigger: EventHookTrigger;
projectPath: string;
featureId?: string;
featureName?: string;
error?: string;
errorType?: string;
passes?: boolean;
metadata?: Record<string, unknown>;
}
/**
* EventHistoryService - Manages persistent storage of events
*/
export class EventHistoryService {
/**
* Store a new event to history
*
* @param input - Event data to store
* @returns Promise resolving to the stored event
*/
async storeEvent(input: StoreEventInput): Promise<StoredEvent> {
const { projectPath, trigger, featureId, featureName, error, errorType, passes, metadata } =
input;
// Ensure events directory exists
await ensureEventHistoryDir(projectPath);
const eventId = `evt-${Date.now()}-${randomUUID().slice(0, 8)}`;
const timestamp = new Date().toISOString();
const projectName = this.extractProjectName(projectPath);
const event: StoredEvent = {
id: eventId,
trigger,
timestamp,
projectPath,
projectName,
featureId,
featureName,
error,
errorType,
passes,
metadata,
};
// Write the full event to its own file
const eventPath = getEventPath(projectPath, eventId);
await atomicWriteJson(eventPath, event);
// Update the index
await this.addToIndex(projectPath, event);
logger.info(`Stored event ${eventId} (${trigger}) for project ${projectName}`);
return event;
}
/**
* Get all events for a project with optional filtering
*
* @param projectPath - Absolute path to project directory
* @param filter - Optional filter criteria
* @returns Promise resolving to array of event summaries
*/
async getEvents(projectPath: string, filter?: EventHistoryFilter): Promise<StoredEventSummary[]> {
const indexPath = getEventHistoryIndexPath(projectPath);
const index = await readJsonFile<StoredEventIndex>(indexPath, DEFAULT_EVENT_HISTORY_INDEX);
let events = [...index.events];
// Apply filters
if (filter) {
if (filter.trigger) {
events = events.filter((e) => e.trigger === filter.trigger);
}
if (filter.featureId) {
events = events.filter((e) => e.featureId === filter.featureId);
}
if (filter.since) {
const sinceDate = new Date(filter.since).getTime();
events = events.filter((e) => new Date(e.timestamp).getTime() >= sinceDate);
}
if (filter.until) {
const untilDate = new Date(filter.until).getTime();
events = events.filter((e) => new Date(e.timestamp).getTime() <= untilDate);
}
}
// Sort by timestamp (newest first)
events.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
// Apply pagination
if (filter?.offset) {
events = events.slice(filter.offset);
}
if (filter?.limit) {
events = events.slice(0, filter.limit);
}
return events;
}
/**
* Get a single event by ID
*
* @param projectPath - Absolute path to project directory
* @param eventId - Event identifier
* @returns Promise resolving to the full event or null if not found
*/
async getEvent(projectPath: string, eventId: string): Promise<StoredEvent | null> {
const eventPath = getEventPath(projectPath, eventId);
try {
const content = (await secureFs.readFile(eventPath, 'utf-8')) as string;
return JSON.parse(content) as StoredEvent;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return null;
}
logger.error(`Error reading event ${eventId}:`, error);
return null;
}
}
/**
* Delete an event by ID
*
* @param projectPath - Absolute path to project directory
* @param eventId - Event identifier
* @returns Promise resolving to true if deleted
*/
async deleteEvent(projectPath: string, eventId: string): Promise<boolean> {
// Remove from index
const indexPath = getEventHistoryIndexPath(projectPath);
const index = await readJsonFile<StoredEventIndex>(indexPath, DEFAULT_EVENT_HISTORY_INDEX);
const initialLength = index.events.length;
index.events = index.events.filter((e) => e.id !== eventId);
if (index.events.length === initialLength) {
return false; // Event not found in index
}
await atomicWriteJson(indexPath, index);
// Delete the event file
const eventPath = getEventPath(projectPath, eventId);
try {
await secureFs.unlink(eventPath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
logger.error(`Error deleting event file ${eventId}:`, error);
}
}
logger.info(`Deleted event ${eventId}`);
return true;
}
/**
* Clear all events for a project
*
* @param projectPath - Absolute path to project directory
* @returns Promise resolving to number of events cleared
*/
async clearEvents(projectPath: string): Promise<number> {
const indexPath = getEventHistoryIndexPath(projectPath);
const index = await readJsonFile<StoredEventIndex>(indexPath, DEFAULT_EVENT_HISTORY_INDEX);
const count = index.events.length;
// Delete all event files
for (const event of index.events) {
const eventPath = getEventPath(projectPath, event.id);
try {
await secureFs.unlink(eventPath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
logger.error(`Error deleting event file ${event.id}:`, error);
}
}
}
// Reset the index
await atomicWriteJson(indexPath, DEFAULT_EVENT_HISTORY_INDEX);
logger.info(`Cleared ${count} events for project`);
return count;
}
/**
* Get event count for a project
*
* @param projectPath - Absolute path to project directory
* @param filter - Optional filter criteria
* @returns Promise resolving to event count
*/
async getEventCount(projectPath: string, filter?: EventHistoryFilter): Promise<number> {
const events = await this.getEvents(projectPath, {
...filter,
limit: undefined,
offset: undefined,
});
return events.length;
}
/**
* Add an event to the index (internal)
*/
private async addToIndex(projectPath: string, event: StoredEvent): Promise<void> {
const indexPath = getEventHistoryIndexPath(projectPath);
const index = await readJsonFile<StoredEventIndex>(indexPath, DEFAULT_EVENT_HISTORY_INDEX);
const summary: StoredEventSummary = {
id: event.id,
trigger: event.trigger,
timestamp: event.timestamp,
featureName: event.featureName,
featureId: event.featureId,
};
// Add to beginning (newest first)
index.events.unshift(summary);
// Prune old events if over limit
if (index.events.length > MAX_EVENTS_IN_INDEX) {
const removed = index.events.splice(MAX_EVENTS_IN_INDEX);
// Delete the pruned event files
for (const oldEvent of removed) {
const eventPath = getEventPath(projectPath, oldEvent.id);
try {
await secureFs.unlink(eventPath);
} catch {
// Ignore deletion errors for pruned events
}
}
logger.info(`Pruned ${removed.length} old events from history`);
}
await atomicWriteJson(indexPath, index);
}
/**
* Extract project name from path
*/
private extractProjectName(projectPath: string): string {
const parts = projectPath.split(/[/\\]/);
return parts[parts.length - 1] || projectPath;
}
}
// Singleton instance
let eventHistoryServiceInstance: EventHistoryService | null = null;
/**
* Get the singleton event history service instance
*/
export function getEventHistoryService(): EventHistoryService {
if (!eventHistoryServiceInstance) {
eventHistoryServiceInstance = new EventHistoryService();
}
return eventHistoryServiceInstance;
}

View File

@@ -0,0 +1,373 @@
/**
* Event Hook Service - Executes custom actions when system events occur
*
* Listens to the event emitter and triggers configured hooks:
* - Shell commands: Executed with configurable timeout
* - HTTP webhooks: POST/GET/PUT/PATCH requests with variable substitution
*
* Also stores events to history for debugging and replay.
*
* Supported events:
* - feature_created: A new feature was created
* - feature_success: Feature completed successfully
* - feature_error: Feature failed with an error
* - auto_mode_complete: Auto mode finished all features (idle state)
* - auto_mode_error: Auto mode encountered a critical error
*/
import { exec } from 'child_process';
import { promisify } from 'util';
import { createLogger } from '@automaker/utils';
import type { EventEmitter } from '../lib/events.js';
import type { SettingsService } from './settings-service.js';
import type { EventHistoryService } from './event-history-service.js';
import type {
EventHook,
EventHookTrigger,
EventHookShellAction,
EventHookHttpAction,
} from '@automaker/types';
const execAsync = promisify(exec);
const logger = createLogger('EventHooks');
/** Default timeout for shell commands (30 seconds) */
const DEFAULT_SHELL_TIMEOUT = 30000;
/** Default timeout for HTTP requests (10 seconds) */
const DEFAULT_HTTP_TIMEOUT = 10000;
/**
* Context available for variable substitution in hooks
*/
interface HookContext {
featureId?: string;
featureName?: string;
projectPath?: string;
projectName?: string;
error?: string;
errorType?: string;
timestamp: string;
eventType: EventHookTrigger;
}
/**
* Auto-mode event payload structure
*/
interface AutoModeEventPayload {
type?: string;
featureId?: string;
passes?: boolean;
message?: string;
error?: string;
errorType?: string;
projectPath?: string;
}
/**
* Feature created event payload structure
*/
interface FeatureCreatedPayload {
featureId: string;
featureName?: string;
projectPath: string;
}
/**
* Event Hook Service
*
* Manages execution of user-configured event hooks in response to system events.
* Also stores events to history for debugging and replay.
*/
export class EventHookService {
private emitter: EventEmitter | null = null;
private settingsService: SettingsService | null = null;
private eventHistoryService: EventHistoryService | null = null;
private unsubscribe: (() => void) | null = null;
/**
* Initialize the service with event emitter, settings service, and event history service
*/
initialize(
emitter: EventEmitter,
settingsService: SettingsService,
eventHistoryService?: EventHistoryService
): void {
this.emitter = emitter;
this.settingsService = settingsService;
this.eventHistoryService = eventHistoryService || null;
// Subscribe to events
this.unsubscribe = emitter.subscribe((type, payload) => {
if (type === 'auto-mode:event') {
this.handleAutoModeEvent(payload as AutoModeEventPayload);
} else if (type === 'feature:created') {
this.handleFeatureCreatedEvent(payload as FeatureCreatedPayload);
}
});
logger.info('Event hook service initialized');
}
/**
* Cleanup subscriptions
*/
destroy(): void {
if (this.unsubscribe) {
this.unsubscribe();
this.unsubscribe = null;
}
this.emitter = null;
this.settingsService = null;
this.eventHistoryService = null;
}
/**
* Handle auto-mode events and trigger matching hooks
*/
private async handleAutoModeEvent(payload: AutoModeEventPayload): Promise<void> {
if (!payload.type) return;
// Map internal event types to hook triggers
let trigger: EventHookTrigger | null = null;
switch (payload.type) {
case 'auto_mode_feature_complete':
trigger = payload.passes ? 'feature_success' : 'feature_error';
break;
case 'auto_mode_error':
// Feature-level error (has featureId) vs auto-mode level error
trigger = payload.featureId ? 'feature_error' : 'auto_mode_error';
break;
case 'auto_mode_idle':
trigger = 'auto_mode_complete';
break;
default:
// Other event types don't trigger hooks
return;
}
if (!trigger) return;
// Build context for variable substitution
const context: HookContext = {
featureId: payload.featureId,
projectPath: payload.projectPath,
projectName: payload.projectPath ? this.extractProjectName(payload.projectPath) : undefined,
error: payload.error || payload.message,
errorType: payload.errorType,
timestamp: new Date().toISOString(),
eventType: trigger,
};
// Execute matching hooks (pass passes for feature completion events)
await this.executeHooksForTrigger(trigger, context, { passes: payload.passes });
}
/**
* Handle feature:created events and trigger matching hooks
*/
private async handleFeatureCreatedEvent(payload: FeatureCreatedPayload): Promise<void> {
const context: HookContext = {
featureId: payload.featureId,
featureName: payload.featureName,
projectPath: payload.projectPath,
projectName: this.extractProjectName(payload.projectPath),
timestamp: new Date().toISOString(),
eventType: 'feature_created',
};
await this.executeHooksForTrigger('feature_created', context);
}
/**
* Execute all enabled hooks matching the given trigger and store event to history
*/
private async executeHooksForTrigger(
trigger: EventHookTrigger,
context: HookContext,
additionalData?: { passes?: boolean }
): Promise<void> {
// Store event to history (even if no hooks match)
if (this.eventHistoryService && context.projectPath) {
try {
await this.eventHistoryService.storeEvent({
trigger,
projectPath: context.projectPath,
featureId: context.featureId,
featureName: context.featureName,
error: context.error,
errorType: context.errorType,
passes: additionalData?.passes,
});
} catch (error) {
logger.error('Failed to store event to history:', error);
}
}
if (!this.settingsService) {
logger.warn('Settings service not available');
return;
}
try {
const settings = await this.settingsService.getGlobalSettings();
const hooks = settings.eventHooks || [];
// Filter to enabled hooks matching this trigger
const matchingHooks = hooks.filter((hook) => hook.enabled && hook.trigger === trigger);
if (matchingHooks.length === 0) {
return;
}
logger.info(`Executing ${matchingHooks.length} hook(s) for trigger: ${trigger}`);
// Execute hooks in parallel (don't wait for one to finish before starting next)
await Promise.allSettled(matchingHooks.map((hook) => this.executeHook(hook, context)));
} catch (error) {
logger.error('Error executing hooks:', error);
}
}
/**
* Execute a single hook
*/
private async executeHook(hook: EventHook, context: HookContext): Promise<void> {
const hookName = hook.name || hook.id;
try {
if (hook.action.type === 'shell') {
await this.executeShellHook(hook.action, context, hookName);
} else if (hook.action.type === 'http') {
await this.executeHttpHook(hook.action, context, hookName);
}
} catch (error) {
logger.error(`Hook "${hookName}" failed:`, error);
}
}
/**
* Execute a shell command hook
*/
private async executeShellHook(
action: EventHookShellAction,
context: HookContext,
hookName: string
): Promise<void> {
const command = this.substituteVariables(action.command, context);
const timeout = action.timeout || DEFAULT_SHELL_TIMEOUT;
logger.info(`Executing shell hook "${hookName}": ${command}`);
try {
const { stdout, stderr } = await execAsync(command, {
timeout,
maxBuffer: 1024 * 1024, // 1MB buffer
});
if (stdout) {
logger.debug(`Hook "${hookName}" stdout: ${stdout.trim()}`);
}
if (stderr) {
logger.warn(`Hook "${hookName}" stderr: ${stderr.trim()}`);
}
logger.info(`Shell hook "${hookName}" completed successfully`);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ETIMEDOUT') {
logger.error(`Shell hook "${hookName}" timed out after ${timeout}ms`);
}
throw error;
}
}
/**
* Execute an HTTP webhook hook
*/
private async executeHttpHook(
action: EventHookHttpAction,
context: HookContext,
hookName: string
): Promise<void> {
const url = this.substituteVariables(action.url, context);
const method = action.method || 'POST';
// Substitute variables in headers
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (action.headers) {
for (const [key, value] of Object.entries(action.headers)) {
headers[key] = this.substituteVariables(value, context);
}
}
// Substitute variables in body
let body: string | undefined;
if (action.body) {
body = this.substituteVariables(action.body, context);
} else if (method !== 'GET') {
// Default body with context information
body = JSON.stringify({
eventType: context.eventType,
timestamp: context.timestamp,
featureId: context.featureId,
projectPath: context.projectPath,
projectName: context.projectName,
error: context.error,
});
}
logger.info(`Executing HTTP hook "${hookName}": ${method} ${url}`);
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), DEFAULT_HTTP_TIMEOUT);
const response = await fetch(url, {
method,
headers,
body: method !== 'GET' ? body : undefined,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
logger.warn(`HTTP hook "${hookName}" received status ${response.status}`);
} else {
logger.info(`HTTP hook "${hookName}" completed successfully (status: ${response.status})`);
}
} catch (error) {
if ((error as Error).name === 'AbortError') {
logger.error(`HTTP hook "${hookName}" timed out after ${DEFAULT_HTTP_TIMEOUT}ms`);
}
throw error;
}
}
/**
* Substitute {{variable}} placeholders in a string
*/
private substituteVariables(template: string, context: HookContext): string {
return template.replace(/\{\{(\w+)\}\}/g, (match, variable) => {
const value = context[variable as keyof HookContext];
if (value === undefined || value === null) {
return '';
}
return String(value);
});
}
/**
* Extract project name from path
*/
private extractProjectName(projectPath: string): string {
const parts = projectPath.split(/[/\\]/);
return parts[parts.length - 1] || projectPath;
}
}
// Singleton instance
export const eventHookService = new EventHookService();

View File

@@ -5,14 +5,22 @@
import path from 'path';
import type { Feature, DescriptionHistoryEntry } from '@automaker/types';
import { createLogger } from '@automaker/utils';
import {
createLogger,
atomicWriteJson,
readJsonWithRecovery,
logRecoveryWarning,
DEFAULT_BACKUP_COUNT,
} from '@automaker/utils';
import * as secureFs from '../lib/secure-fs.js';
import {
getFeaturesDir,
getFeatureDir,
getFeatureImagesDir,
getAppSpecPath,
ensureAutomakerDir,
} from '@automaker/platform';
import { addImplementedFeature, type ImplementedFeature } from '../lib/xml-extractor.js';
const logger = createLogger('FeatureLoader');
@@ -192,31 +200,31 @@ export class FeatureLoader {
})) as any[];
const featureDirs = entries.filter((entry) => entry.isDirectory());
// Load all features concurrently (secureFs has built-in concurrency limiting)
// Load all features concurrently with automatic recovery from backups
const featurePromises = featureDirs.map(async (dir) => {
const featureId = dir.name;
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
try {
const content = (await secureFs.readFile(featureJsonPath, 'utf-8')) as string;
const feature = JSON.parse(content);
// Use recovery-enabled read to handle corrupted files
const result = await readJsonWithRecovery<Feature | null>(featureJsonPath, null, {
maxBackups: DEFAULT_BACKUP_COUNT,
autoRestore: true,
});
if (!feature.id) {
logger.warn(`Feature ${featureId} missing required 'id' field, skipping`);
return null;
}
logRecoveryWarning(result, `Feature ${featureId}`, logger);
return feature as Feature;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return null;
} else if (error instanceof SyntaxError) {
logger.warn(`Failed to parse feature.json for ${featureId}: ${error.message}`);
} else {
logger.error(`Failed to load feature ${featureId}:`, (error as Error).message);
}
const feature = result.data;
if (!feature) {
return null;
}
if (!feature.id) {
logger.warn(`Feature ${featureId} missing required 'id' field, skipping`);
return null;
}
return feature;
});
const results = await Promise.all(featurePromises);
@@ -236,21 +244,85 @@ export class FeatureLoader {
}
}
/**
* Normalize a title for comparison (case-insensitive, trimmed)
*/
private normalizeTitle(title: string): string {
return title.toLowerCase().trim();
}
/**
* Find a feature by its title (case-insensitive match)
* @param projectPath - Path to the project
* @param title - Title to search for
* @returns The matching feature or null if not found
*/
async findByTitle(projectPath: string, title: string): Promise<Feature | null> {
if (!title || !title.trim()) {
return null;
}
const normalizedTitle = this.normalizeTitle(title);
const features = await this.getAll(projectPath);
for (const feature of features) {
if (feature.title && this.normalizeTitle(feature.title) === normalizedTitle) {
return feature;
}
}
return null;
}
/**
* Check if a title already exists on another feature (for duplicate detection)
* @param projectPath - Path to the project
* @param title - Title to check
* @param excludeFeatureId - Optional feature ID to exclude from the check (for updates)
* @returns The duplicate feature if found, null otherwise
*/
async findDuplicateTitle(
projectPath: string,
title: string,
excludeFeatureId?: string
): Promise<Feature | null> {
if (!title || !title.trim()) {
return null;
}
const normalizedTitle = this.normalizeTitle(title);
const features = await this.getAll(projectPath);
for (const feature of features) {
// Skip the feature being updated (if provided)
if (excludeFeatureId && feature.id === excludeFeatureId) {
continue;
}
if (feature.title && this.normalizeTitle(feature.title) === normalizedTitle) {
return feature;
}
}
return null;
}
/**
* Get a single feature by ID
* Uses automatic recovery from backups if the main file is corrupted
*/
async get(projectPath: string, featureId: string): Promise<Feature | null> {
try {
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
const content = (await secureFs.readFile(featureJsonPath, 'utf-8')) as string;
return JSON.parse(content);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return null;
}
logger.error(`Failed to get feature ${featureId}:`, error);
throw error;
}
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
// Use recovery-enabled read to handle corrupted files
const result = await readJsonWithRecovery<Feature | null>(featureJsonPath, null, {
maxBackups: DEFAULT_BACKUP_COUNT,
autoRestore: true,
});
logRecoveryWarning(result, `Feature ${featureId}`, logger);
return result.data;
}
/**
@@ -294,8 +366,8 @@ export class FeatureLoader {
descriptionHistory: initialHistory,
};
// Write feature.json
await secureFs.writeFile(featureJsonPath, JSON.stringify(feature, null, 2), 'utf-8');
// Write feature.json atomically with backup support
await atomicWriteJson(featureJsonPath, feature, { backupCount: DEFAULT_BACKUP_COUNT });
logger.info(`Created feature ${featureId}`);
return feature;
@@ -379,9 +451,9 @@ export class FeatureLoader {
descriptionHistory: updatedHistory,
};
// Write back to file
// Write back to file atomically with backup support
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
await secureFs.writeFile(featureJsonPath, JSON.stringify(updatedFeature, null, 2), 'utf-8');
await atomicWriteJson(featureJsonPath, updatedFeature, { backupCount: DEFAULT_BACKUP_COUNT });
logger.info(`Updated feature ${featureId}`);
return updatedFeature;
@@ -460,4 +532,64 @@ export class FeatureLoader {
}
}
}
/**
* Sync a completed feature to the app_spec.txt implemented_features section
*
* When a feature is completed, this method adds it to the implemented_features
* section of the project's app_spec.txt file. This keeps the spec in sync
* with the actual state of the codebase.
*
* @param projectPath - Path to the project
* @param feature - The feature to sync (must have title or description)
* @param fileLocations - Optional array of file paths where the feature was implemented
* @returns True if the spec was updated, false if no spec exists or feature was skipped
*/
async syncFeatureToAppSpec(
projectPath: string,
feature: Feature,
fileLocations?: string[]
): Promise<boolean> {
try {
const appSpecPath = getAppSpecPath(projectPath);
// Read the current app_spec.txt
let specContent: string;
try {
specContent = (await secureFs.readFile(appSpecPath, 'utf-8')) as string;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
logger.info(`No app_spec.txt found for project, skipping sync for feature ${feature.id}`);
return false;
}
throw error;
}
// Build the implemented feature entry
const featureName = feature.title || `Feature: ${feature.id}`;
const implementedFeature: ImplementedFeature = {
name: featureName,
description: feature.description,
...(fileLocations && fileLocations.length > 0 ? { file_locations: fileLocations } : {}),
};
// Add the feature to the implemented_features section
const updatedSpecContent = addImplementedFeature(specContent, implementedFeature);
// Check if the content actually changed (feature might already exist)
if (updatedSpecContent === specContent) {
logger.info(`Feature "${featureName}" already exists in app_spec.txt, skipping`);
return false;
}
// Write the updated spec back to the file
await secureFs.writeFile(appSpecPath, updatedSpecContent, 'utf-8');
logger.info(`Synced feature "${featureName}" to app_spec.txt`);
return true;
} catch (error) {
logger.error(`Failed to sync feature ${feature.id} to app_spec.txt:`, error);
throw error;
}
}
}

View File

@@ -41,6 +41,7 @@ import type { FeatureLoader } from './feature-loader.js';
import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js';
import { resolveModelString } from '@automaker/model-resolver';
import { stripProviderPrefix } from '@automaker/types';
import { getPromptCustomization, getActiveClaudeApiProfile } from '../lib/settings-helpers.js';
const logger = createLogger('IdeationService');
@@ -195,8 +196,12 @@ export class IdeationService {
// Gather existing features and ideas to prevent duplicate suggestions
const existingWorkContext = await this.gatherExistingWorkContext(projectPath);
// Get customized prompts from settings
const prompts = await getPromptCustomization(this.settingsService, '[IdeationService]');
// Build system prompt for ideation
const systemPrompt = this.buildIdeationSystemPrompt(
prompts.ideation.ideationSystemPrompt,
contextResult.formattedPrompt,
activeSession.session.promptCategory,
existingWorkContext
@@ -218,6 +223,12 @@ export class IdeationService {
// Strip provider prefix - providers need bare model IDs
const bareModel = stripProviderPrefix(modelId);
// Get active Claude API profile for alternative endpoint configuration
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
this.settingsService,
'[IdeationService]'
);
const executeOptions: ExecuteOptions = {
prompt: message,
model: bareModel,
@@ -227,6 +238,8 @@ export class IdeationService {
maxTurns: 1, // Single turn for ideation
abortController: activeSession.abortController!,
conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined,
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
};
const stream = provider.executeQuery(executeOptions);
@@ -645,8 +658,12 @@ export class IdeationService {
// Gather existing features and ideas to prevent duplicates
const existingWorkContext = await this.gatherExistingWorkContext(projectPath);
// Get customized prompts from settings
const prompts = await getPromptCustomization(this.settingsService, '[IdeationService]');
// Build system prompt for structured suggestions
const systemPrompt = this.buildSuggestionsSystemPrompt(
prompts.ideation.suggestionsSystemPrompt,
contextPrompt,
category,
count,
@@ -669,6 +686,12 @@ export class IdeationService {
// Strip provider prefix - providers need bare model IDs
const bareModel = stripProviderPrefix(modelId);
// Get active Claude API profile for alternative endpoint configuration
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
this.settingsService,
'[IdeationService]'
);
const executeOptions: ExecuteOptions = {
prompt: prompt.prompt,
model: bareModel,
@@ -679,6 +702,8 @@ export class IdeationService {
// Disable all tools - we just want text generation, not codebase analysis
allowedTools: [],
abortController: new AbortController(),
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
};
const stream = provider.executeQuery(executeOptions);
@@ -721,8 +746,14 @@ export class IdeationService {
/**
* Build system prompt for structured suggestion generation
* @param basePrompt - The base system prompt from settings
* @param contextFilesPrompt - Project context from loaded files
* @param category - The idea category to focus on
* @param count - Number of suggestions to generate
* @param existingWorkContext - Context about existing features/ideas
*/
private buildSuggestionsSystemPrompt(
basePrompt: string,
contextFilesPrompt: string | undefined,
category: IdeaCategory,
count: number = 10,
@@ -734,35 +765,18 @@ export class IdeationService {
const existingWorkSection = existingWorkContext ? `\n\n${existingWorkContext}` : '';
return `You are an AI product strategist helping brainstorm feature ideas for a software project.
// Replace placeholder {{count}} if present, otherwise append count instruction
let prompt = basePrompt;
if (prompt.includes('{{count}}')) {
prompt = prompt.replace(/\{\{count\}\}/g, String(count));
} else {
prompt += `\n\nGenerate exactly ${count} suggestions.`;
}
IMPORTANT: You do NOT have access to any tools. You CANNOT read files, search code, or run commands.
You must generate suggestions based ONLY on the project context provided below.
Do NOT say "I'll analyze" or "Let me explore" - you cannot do those things.
Based on the project context and the user's prompt, generate exactly ${count} creative and actionable feature suggestions.
YOUR RESPONSE MUST BE ONLY A JSON ARRAY - nothing else. No explanation, no preamble, no markdown code fences.
Each suggestion must have this structure:
{
"title": "Short, actionable title (max 60 chars)",
"description": "Clear description of what to build or improve (2-3 sentences)",
"rationale": "Why this is valuable - the problem it solves or opportunity it creates",
"priority": "high" | "medium" | "low"
}
return `${prompt}
Focus area: ${this.getCategoryDescription(category)}
Guidelines:
- Generate exactly ${count} suggestions
- Be specific and actionable - avoid vague ideas
- Mix different priority levels (some high, some medium, some low)
- Each suggestion should be independently implementable
- Think creatively - include both obvious improvements and innovative ideas
- Consider the project's domain and target users
- IMPORTANT: Do NOT suggest features or ideas that already exist in the "Existing Features" or "Existing Ideas" sections below
${contextSection}${existingWorkSection}`;
}
@@ -1269,30 +1283,11 @@ ${contextSection}${existingWorkSection}`;
// ============================================================================
private buildIdeationSystemPrompt(
basePrompt: string,
contextFilesPrompt: string | undefined,
category?: IdeaCategory,
existingWorkContext?: string
): string {
const basePrompt = `You are an AI product strategist and UX expert helping brainstorm ideas for improving a software project.
Your role is to:
- Analyze the codebase structure and patterns
- Identify opportunities for improvement
- Suggest actionable ideas with clear rationale
- Consider user experience, technical feasibility, and business value
- Be specific and reference actual files/components when possible
When suggesting ideas:
1. Provide a clear, concise title
2. Explain the problem or opportunity
3. Describe the proposed solution
4. Highlight the expected benefit
5. Note any dependencies or considerations
IMPORTANT: Do NOT suggest features or ideas that already exist in the project. Check the "Existing Features" and "Existing Ideas" sections below to avoid duplicates.
Focus on practical, implementable suggestions that would genuinely improve the product.`;
const categoryContext = category
? `\n\nFocus area: ${this.getCategoryDescription(category)}`
: '';

View File

@@ -0,0 +1,280 @@
/**
* Notification Service - Handles reading/writing notifications to JSON files
*
* Provides persistent storage for project-level notifications in
* {projectPath}/.automaker/notifications.json
*
* Notifications alert users when:
* - Features reach specific statuses (waiting_approval, verified)
* - Long-running operations complete (spec generation)
*/
import { createLogger } from '@automaker/utils';
import * as secureFs from '../lib/secure-fs.js';
import { getNotificationsPath, ensureAutomakerDir } from '@automaker/platform';
import type { Notification, NotificationsFile, NotificationType } from '@automaker/types';
import { DEFAULT_NOTIFICATIONS_FILE } from '@automaker/types';
import type { EventEmitter } from '../lib/events.js';
import { randomUUID } from 'crypto';
const logger = createLogger('NotificationService');
/**
* Atomic file write - write to temp file then rename
*/
async function atomicWriteJson(filePath: string, data: unknown): Promise<void> {
const tempPath = `${filePath}.tmp.${Date.now()}`;
const content = JSON.stringify(data, null, 2);
try {
await secureFs.writeFile(tempPath, content, 'utf-8');
await secureFs.rename(tempPath, filePath);
} catch (error) {
// Clean up temp file if it exists
try {
await secureFs.unlink(tempPath);
} catch {
// Ignore cleanup errors
}
throw error;
}
}
/**
* Safely read JSON file with fallback to default
*/
async function readJsonFile<T>(filePath: string, defaultValue: T): Promise<T> {
try {
const content = (await secureFs.readFile(filePath, 'utf-8')) as string;
return JSON.parse(content) as T;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return defaultValue;
}
logger.error(`Error reading ${filePath}:`, error);
return defaultValue;
}
}
/**
* Input for creating a new notification
*/
export interface CreateNotificationInput {
type: NotificationType;
title: string;
message: string;
featureId?: string;
projectPath: string;
}
/**
* NotificationService - Manages persistent storage of notifications
*
* Handles reading and writing notifications to JSON files with atomic operations
* for reliability. Each project has its own notifications.json file.
*/
export class NotificationService {
private events: EventEmitter | null = null;
/**
* Set the event emitter for broadcasting notification events
*/
setEventEmitter(events: EventEmitter): void {
this.events = events;
}
/**
* Get all notifications for a project
*
* @param projectPath - Absolute path to project directory
* @returns Promise resolving to array of notifications
*/
async getNotifications(projectPath: string): Promise<Notification[]> {
const notificationsPath = getNotificationsPath(projectPath);
const file = await readJsonFile<NotificationsFile>(
notificationsPath,
DEFAULT_NOTIFICATIONS_FILE
);
// Filter out dismissed notifications and sort by date (newest first)
return file.notifications
.filter((n) => !n.dismissed)
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
}
/**
* Get unread notification count for a project
*
* @param projectPath - Absolute path to project directory
* @returns Promise resolving to unread count
*/
async getUnreadCount(projectPath: string): Promise<number> {
const notifications = await this.getNotifications(projectPath);
return notifications.filter((n) => !n.read).length;
}
/**
* Create a new notification
*
* @param input - Notification creation input
* @returns Promise resolving to the created notification
*/
async createNotification(input: CreateNotificationInput): Promise<Notification> {
const { projectPath, type, title, message, featureId } = input;
// Ensure automaker directory exists
await ensureAutomakerDir(projectPath);
const notificationsPath = getNotificationsPath(projectPath);
const file = await readJsonFile<NotificationsFile>(
notificationsPath,
DEFAULT_NOTIFICATIONS_FILE
);
const notification: Notification = {
id: randomUUID(),
type,
title,
message,
createdAt: new Date().toISOString(),
read: false,
dismissed: false,
featureId,
projectPath,
};
file.notifications.push(notification);
await atomicWriteJson(notificationsPath, file);
logger.info(`Created notification: ${title} for project ${projectPath}`);
// Emit event for real-time updates
if (this.events) {
this.events.emit('notification:created', notification);
}
return notification;
}
/**
* Mark a notification as read
*
* @param projectPath - Absolute path to project directory
* @param notificationId - ID of the notification to mark as read
* @returns Promise resolving to the updated notification or null if not found
*/
async markAsRead(projectPath: string, notificationId: string): Promise<Notification | null> {
const notificationsPath = getNotificationsPath(projectPath);
const file = await readJsonFile<NotificationsFile>(
notificationsPath,
DEFAULT_NOTIFICATIONS_FILE
);
const notification = file.notifications.find((n) => n.id === notificationId);
if (!notification) {
return null;
}
notification.read = true;
await atomicWriteJson(notificationsPath, file);
logger.info(`Marked notification ${notificationId} as read`);
return notification;
}
/**
* Mark all notifications as read for a project
*
* @param projectPath - Absolute path to project directory
* @returns Promise resolving to number of notifications marked as read
*/
async markAllAsRead(projectPath: string): Promise<number> {
const notificationsPath = getNotificationsPath(projectPath);
const file = await readJsonFile<NotificationsFile>(
notificationsPath,
DEFAULT_NOTIFICATIONS_FILE
);
let count = 0;
for (const notification of file.notifications) {
if (!notification.read && !notification.dismissed) {
notification.read = true;
count++;
}
}
if (count > 0) {
await atomicWriteJson(notificationsPath, file);
logger.info(`Marked ${count} notifications as read`);
}
return count;
}
/**
* Dismiss a notification
*
* @param projectPath - Absolute path to project directory
* @param notificationId - ID of the notification to dismiss
* @returns Promise resolving to true if notification was dismissed
*/
async dismissNotification(projectPath: string, notificationId: string): Promise<boolean> {
const notificationsPath = getNotificationsPath(projectPath);
const file = await readJsonFile<NotificationsFile>(
notificationsPath,
DEFAULT_NOTIFICATIONS_FILE
);
const notification = file.notifications.find((n) => n.id === notificationId);
if (!notification) {
return false;
}
notification.dismissed = true;
await atomicWriteJson(notificationsPath, file);
logger.info(`Dismissed notification ${notificationId}`);
return true;
}
/**
* Dismiss all notifications for a project
*
* @param projectPath - Absolute path to project directory
* @returns Promise resolving to number of notifications dismissed
*/
async dismissAll(projectPath: string): Promise<number> {
const notificationsPath = getNotificationsPath(projectPath);
const file = await readJsonFile<NotificationsFile>(
notificationsPath,
DEFAULT_NOTIFICATIONS_FILE
);
let count = 0;
for (const notification of file.notifications) {
if (!notification.dismissed) {
notification.dismissed = true;
count++;
}
}
if (count > 0) {
await atomicWriteJson(notificationsPath, file);
logger.info(`Dismissed ${count} notifications`);
}
return count;
}
}
// Singleton instance
let notificationServiceInstance: NotificationService | null = null;
/**
* Get the singleton notification service instance
*/
export function getNotificationService(): NotificationService {
if (!notificationServiceInstance) {
notificationServiceInstance = new NotificationService();
}
return notificationServiceInstance;
}

View File

@@ -7,8 +7,11 @@
* - Per-project settings ({projectPath}/.automaker/settings.json)
*/
import { createLogger } from '@automaker/utils';
import { createLogger, atomicWriteJson, DEFAULT_BACKUP_COUNT } from '@automaker/utils';
import * as secureFs from '../lib/secure-fs.js';
import os from 'os';
import path from 'path';
import fs from 'fs/promises';
import {
getGlobalSettingsPath,
@@ -38,32 +41,13 @@ import {
CREDENTIALS_VERSION,
PROJECT_SETTINGS_VERSION,
} from '../types/settings.js';
import { migrateModelId, migrateCursorModelIds, migrateOpencodeModelIds } from '@automaker/types';
const logger = createLogger('SettingsService');
/**
* Atomic file write - write to temp file then rename
*/
async function atomicWriteJson(filePath: string, data: unknown): Promise<void> {
const tempPath = `${filePath}.tmp.${Date.now()}`;
const content = JSON.stringify(data, null, 2);
try {
await secureFs.writeFile(tempPath, content, 'utf-8');
await secureFs.rename(tempPath, filePath);
} catch (error) {
// Clean up temp file if it exists
try {
await secureFs.unlink(tempPath);
} catch {
// Ignore cleanup errors
}
throw error;
}
}
/**
* Safely read JSON file with fallback to default
* Wrapper for readJsonFile from utils that uses the local secureFs
* to maintain compatibility with the server's secure file system
*/
async function readJsonFile<T>(filePath: string, defaultValue: T): Promise<T> {
try {
@@ -90,6 +74,13 @@ async function fileExists(filePath: string): Promise<boolean> {
}
}
/**
* Write settings atomically with backup support
*/
async function writeSettingsJson(filePath: string, data: unknown): Promise<void> {
await atomicWriteJson(filePath, data, { backupCount: DEFAULT_BACKUP_COUNT });
}
/**
* SettingsService - Manages persistent storage of user settings and credentials
*
@@ -137,10 +128,14 @@ export class SettingsService {
// Migrate legacy enhancementModel/validationModel to phaseModels
const migratedPhaseModels = this.migratePhaseModels(settings);
// Migrate model IDs to canonical format
const migratedModelSettings = this.migrateModelSettings(settings);
// Apply any missing defaults (for backwards compatibility)
let result: GlobalSettings = {
...DEFAULT_GLOBAL_SETTINGS,
...settings,
...migratedModelSettings,
keyboardShortcuts: {
...DEFAULT_GLOBAL_SETTINGS.keyboardShortcuts,
...settings.keyboardShortcuts,
@@ -171,6 +166,41 @@ export class SettingsService {
needsSave = true;
}
// Migration v4 -> v5: Auto-create "Direct Anthropic" profile for existing users
// If user has an Anthropic API key in credentials but no profiles, create a
// "Direct Anthropic" profile that references the credentials and set it as active.
if (storedVersion < 5) {
try {
const credentials = await this.getCredentials();
const hasAnthropicKey = !!credentials.apiKeys?.anthropic;
const hasNoProfiles = !result.claudeApiProfiles || result.claudeApiProfiles.length === 0;
const hasNoActiveProfile = !result.activeClaudeApiProfileId;
if (hasAnthropicKey && hasNoProfiles && hasNoActiveProfile) {
const directAnthropicProfile = {
id: `profile-${Date.now()}-direct-anthropic`,
name: 'Direct Anthropic',
baseUrl: 'https://api.anthropic.com',
apiKeySource: 'credentials' as const,
useAuthToken: false,
};
result.claudeApiProfiles = [directAnthropicProfile];
result.activeClaudeApiProfileId = directAnthropicProfile.id;
logger.info(
'Migration v4->v5: Created "Direct Anthropic" profile using existing credentials'
);
}
} catch (error) {
logger.warn(
'Migration v4->v5: Could not check credentials for auto-profile creation:',
error
);
}
needsSave = true;
}
// Update version if any migration occurred
if (needsSave) {
result.version = SETTINGS_VERSION;
@@ -180,7 +210,7 @@ export class SettingsService {
if (needsSave) {
try {
await ensureDataDir(this.dataDir);
await atomicWriteJson(settingsPath, result);
await writeSettingsJson(settingsPath, result);
logger.info('Settings migration complete');
} catch (error) {
logger.error('Failed to save migrated settings:', error);
@@ -236,19 +266,70 @@ export class SettingsService {
* Convert a phase model value to PhaseModelEntry format
*
* Handles migration from string format (v2) to object format (v3).
* - String values like 'sonnet' become { model: 'sonnet' }
* - Object values are returned as-is (with type assertion)
* Also migrates legacy model IDs to canonical prefixed format.
* - String values like 'sonnet' become { model: 'claude-sonnet' }
* - Object values have their model ID migrated if needed
*
* @param value - Phase model value (string or PhaseModelEntry)
* @returns PhaseModelEntry object
* @returns PhaseModelEntry object with canonical model ID
*/
private toPhaseModelEntry(value: string | PhaseModelEntry): PhaseModelEntry {
if (typeof value === 'string') {
// v2 format: just a model string
return { model: value as PhaseModelEntry['model'] };
// v2 format: just a model string - migrate to canonical ID
return { model: migrateModelId(value) as PhaseModelEntry['model'] };
}
// v3 format: already a PhaseModelEntry object
return value;
// v3 format: PhaseModelEntry object - migrate model ID if needed
return {
...value,
model: migrateModelId(value.model) as PhaseModelEntry['model'],
};
}
/**
* Migrate model-related settings to canonical format
*
* Migrates:
* - enabledCursorModels: legacy IDs to cursor- prefixed
* - enabledOpencodeModels: legacy slash format to dash format
* - cursorDefaultModel: legacy ID to cursor- prefixed
*
* @param settings - Settings to migrate
* @returns Settings with migrated model IDs
*/
private migrateModelSettings(settings: Partial<GlobalSettings>): Partial<GlobalSettings> {
const migrated: Partial<GlobalSettings> = { ...settings };
// Migrate Cursor models
if (settings.enabledCursorModels) {
migrated.enabledCursorModels = migrateCursorModelIds(
settings.enabledCursorModels as string[]
);
}
// Migrate Cursor default model
if (settings.cursorDefaultModel) {
const migratedDefault = migrateCursorModelIds([settings.cursorDefaultModel as string]);
if (migratedDefault.length > 0) {
migrated.cursorDefaultModel = migratedDefault[0];
}
}
// Migrate OpenCode models
if (settings.enabledOpencodeModels) {
migrated.enabledOpencodeModels = migrateOpencodeModelIds(
settings.enabledOpencodeModels as string[]
);
}
// Migrate OpenCode default model
if (settings.opencodeDefaultModel) {
const migratedDefault = migrateOpencodeModelIds([settings.opencodeDefaultModel as string]);
if (migratedDefault.length > 0) {
migrated.opencodeDefaultModel = migratedDefault[0];
}
}
return migrated;
}
/**
@@ -286,13 +367,39 @@ export class SettingsService {
};
const currentProjectsLen = Array.isArray(current.projects) ? current.projects.length : 0;
// Check if this is a legitimate project removal (moved to trash) vs accidental wipe
const newTrashedProjectsLen = Array.isArray(sanitizedUpdates.trashedProjects)
? sanitizedUpdates.trashedProjects.length
: Array.isArray(current.trashedProjects)
? current.trashedProjects.length
: 0;
if (
Array.isArray(sanitizedUpdates.projects) &&
sanitizedUpdates.projects.length === 0 &&
currentProjectsLen > 0
) {
attemptedProjectWipe = true;
delete sanitizedUpdates.projects;
// Only treat as accidental wipe if trashedProjects is also empty
// (If projects are moved to trash, they appear in trashedProjects)
if (newTrashedProjectsLen === 0) {
logger.warn(
'[WIPE_PROTECTION] Attempted to set projects to empty array with no trash! Ignoring update.',
{
currentProjectsLen,
newProjectsLen: 0,
newTrashedProjectsLen,
currentProjects: current.projects?.map((p) => p.name),
}
);
attemptedProjectWipe = true;
delete sanitizedUpdates.projects;
} else {
logger.info('[LEGITIMATE_REMOVAL] Removing all projects to trash', {
currentProjectsLen,
newProjectsLen: 0,
movedToTrash: newTrashedProjectsLen,
});
}
}
ignoreEmptyArrayOverwrite('trashedProjects');
@@ -340,7 +447,7 @@ export class SettingsService {
};
}
await atomicWriteJson(settingsPath, updated);
await writeSettingsJson(settingsPath, updated);
logger.info('Global settings updated');
return updated;
@@ -414,7 +521,7 @@ export class SettingsService {
};
}
await atomicWriteJson(credentialsPath, updated);
await writeSettingsJson(credentialsPath, updated);
logger.info('Credentials updated');
return updated;
@@ -525,7 +632,7 @@ export class SettingsService {
};
}
await atomicWriteJson(settingsPath, updated);
await writeSettingsJson(settingsPath, updated);
logger.info(`Project settings updated for ${projectPath}`);
return updated;
@@ -779,4 +886,203 @@ export class SettingsService {
getDataDir(): string {
return this.dataDir;
}
/**
* Get the legacy Electron userData directory path
*
* Returns the platform-specific path where Electron previously stored settings
* before the migration to shared data directories.
*
* @returns Absolute path to legacy userData directory
*/
private getLegacyElectronUserDataPath(): string {
const homeDir = os.homedir();
switch (process.platform) {
case 'darwin':
// macOS: ~/Library/Application Support/Automaker
return path.join(homeDir, 'Library', 'Application Support', 'Automaker');
case 'win32':
// Windows: %APPDATA%\Automaker
return path.join(
process.env.APPDATA || path.join(homeDir, 'AppData', 'Roaming'),
'Automaker'
);
default:
// Linux and others: ~/.config/Automaker
return path.join(process.env.XDG_CONFIG_HOME || path.join(homeDir, '.config'), 'Automaker');
}
}
/**
* Migrate entire data directory from legacy Electron userData location to new shared data directory
*
* This handles the migration from when Electron stored data in the platform-specific
* userData directory (e.g., ~/.config/Automaker) to the new shared ./data directory.
*
* Migration only occurs if:
* 1. The new location does NOT have settings.json
* 2. The legacy location DOES have settings.json
*
* Migrates all files and directories including:
* - settings.json (global settings)
* - credentials.json (API keys)
* - sessions-metadata.json (chat session metadata)
* - agent-sessions/ (conversation histories)
* - Any other files in the data directory
*
* @returns Promise resolving to migration result
*/
async migrateFromLegacyElectronPath(): Promise<{
migrated: boolean;
migratedFiles: string[];
legacyPath: string;
errors: string[];
}> {
const legacyPath = this.getLegacyElectronUserDataPath();
const migratedFiles: string[] = [];
const errors: string[] = [];
// Skip if legacy path is the same as current data dir (no migration needed)
if (path.resolve(legacyPath) === path.resolve(this.dataDir)) {
logger.debug('Legacy path same as current data dir, skipping migration');
return { migrated: false, migratedFiles, legacyPath, errors };
}
logger.info(`Checking for legacy data migration from: ${legacyPath}`);
logger.info(`Current data directory: ${this.dataDir}`);
// Check if new settings already exist
const newSettingsPath = getGlobalSettingsPath(this.dataDir);
let newSettingsExist = false;
try {
await fs.access(newSettingsPath);
newSettingsExist = true;
} catch {
// New settings don't exist, migration may be needed
}
if (newSettingsExist) {
logger.debug('Settings already exist in new location, skipping migration');
return { migrated: false, migratedFiles, legacyPath, errors };
}
// Check if legacy directory exists and has settings
const legacySettingsPath = path.join(legacyPath, 'settings.json');
let legacySettingsExist = false;
try {
await fs.access(legacySettingsPath);
legacySettingsExist = true;
} catch {
// Legacy settings don't exist
}
if (!legacySettingsExist) {
logger.debug('No legacy settings found, skipping migration');
return { migrated: false, migratedFiles, legacyPath, errors };
}
// Perform migration of specific application data files only
// (not Electron internal caches like Code Cache, GPU Cache, etc.)
logger.info('Found legacy data directory, migrating application data to new location...');
// Ensure new data directory exists
try {
await ensureDataDir(this.dataDir);
} catch (error) {
const msg = `Failed to create data directory: ${error}`;
logger.error(msg);
errors.push(msg);
return { migrated: false, migratedFiles, legacyPath, errors };
}
// Only migrate specific application data files/directories
const itemsToMigrate = [
'settings.json',
'credentials.json',
'sessions-metadata.json',
'agent-sessions',
'.api-key',
'.sessions',
];
for (const item of itemsToMigrate) {
const srcPath = path.join(legacyPath, item);
const destPath = path.join(this.dataDir, item);
// Check if source exists
try {
await fs.access(srcPath);
} catch {
// Source doesn't exist, skip
continue;
}
// Check if destination already exists
try {
await fs.access(destPath);
logger.debug(`Skipping ${item} - already exists in destination`);
continue;
} catch {
// Destination doesn't exist, proceed with copy
}
// Copy file or directory
try {
const stat = await fs.stat(srcPath);
if (stat.isDirectory()) {
await this.copyDirectory(srcPath, destPath);
migratedFiles.push(item + '/');
logger.info(`Migrated directory: ${item}/`);
} else {
const content = await fs.readFile(srcPath);
await fs.writeFile(destPath, content);
migratedFiles.push(item);
logger.info(`Migrated file: ${item}`);
}
} catch (error) {
const msg = `Failed to migrate ${item}: ${error}`;
logger.error(msg);
errors.push(msg);
}
}
if (migratedFiles.length > 0) {
logger.info(
`Migration complete. Migrated ${migratedFiles.length} item(s): ${migratedFiles.join(', ')}`
);
logger.info(`Legacy path: ${legacyPath}`);
logger.info(`New path: ${this.dataDir}`);
}
return {
migrated: migratedFiles.length > 0,
migratedFiles,
legacyPath,
errors,
};
}
/**
* Recursively copy a directory from source to destination
*
* @param srcDir - Source directory path
* @param destDir - Destination directory path
*/
private async copyDirectory(srcDir: string, destDir: string): Promise<void> {
await fs.mkdir(destDir, { recursive: true });
const entries = await fs.readdir(srcDir, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(srcDir, entry.name);
const destPath = path.join(destDir, entry.name);
if (entry.isDirectory()) {
await this.copyDirectory(srcPath, destPath);
} else if (entry.isFile()) {
const content = await fs.readFile(srcPath);
await fs.writeFile(destPath, content);
}
}
}
}

View File

@@ -70,6 +70,29 @@ export class TerminalService extends EventEmitter {
private sessions: Map<string, TerminalSession> = new Map();
private dataCallbacks: Set<DataCallback> = new Set();
private exitCallbacks: Set<ExitCallback> = new Set();
private isWindows = os.platform() === 'win32';
// On Windows, ConPTY requires AttachConsole which fails in Electron/service mode
// Detect Electron by checking for electron-specific env vars or process properties
private isElectron =
!!(process.versions && (process.versions as Record<string, string>).electron) ||
!!process.env.ELECTRON_RUN_AS_NODE;
private useConptyFallback = false; // Track if we need to use winpty fallback on Windows
/**
* Kill a PTY process with platform-specific handling.
* Windows doesn't support Unix signals like SIGTERM/SIGKILL, so we call kill() without arguments.
* On Unix-like systems (macOS, Linux), we can specify the signal.
*
* @param ptyProcess - The PTY process to kill
* @param signal - The signal to send on Unix-like systems (default: 'SIGTERM')
*/
private killPtyProcess(ptyProcess: pty.IPty, signal: string = 'SIGTERM'): void {
if (this.isWindows) {
ptyProcess.kill();
} else {
ptyProcess.kill(signal);
}
}
/**
* Detect the best shell for the current platform
@@ -322,13 +345,60 @@ export class TerminalService extends EventEmitter {
logger.info(`Creating session ${id} with shell: ${shell} in ${cwd}`);
const ptyProcess = pty.spawn(shell, shellArgs, {
// Build PTY spawn options
const ptyOptions: pty.IPtyForkOptions = {
name: 'xterm-256color',
cols: options.cols || 80,
rows: options.rows || 24,
cwd,
env,
});
};
// On Windows, always use winpty instead of ConPTY
// ConPTY requires AttachConsole which fails in many contexts:
// - Electron apps without a console
// - VS Code integrated terminal
// - Spawned from other applications
// The error happens in a subprocess so we can't catch it - must proactively disable
if (this.isWindows) {
(ptyOptions as pty.IWindowsPtyForkOptions).useConpty = false;
logger.info(
`[createSession] Using winpty for session ${id} (ConPTY disabled for compatibility)`
);
}
let ptyProcess: pty.IPty;
try {
ptyProcess = pty.spawn(shell, shellArgs, ptyOptions);
} catch (spawnError) {
const errorMessage = spawnError instanceof Error ? spawnError.message : String(spawnError);
// Check for Windows ConPTY-specific errors
if (this.isWindows && errorMessage.includes('AttachConsole failed')) {
// ConPTY failed - try winpty fallback
if (!this.useConptyFallback) {
logger.warn(`[createSession] ConPTY AttachConsole failed, retrying with winpty fallback`);
this.useConptyFallback = true;
try {
(ptyOptions as pty.IWindowsPtyForkOptions).useConpty = false;
ptyProcess = pty.spawn(shell, shellArgs, ptyOptions);
logger.info(`[createSession] Successfully spawned session ${id} with winpty fallback`);
} catch (fallbackError) {
const fallbackMessage =
fallbackError instanceof Error ? fallbackError.message : String(fallbackError);
logger.error(`[createSession] Winpty fallback also failed:`, fallbackMessage);
return null;
}
} else {
logger.error(`[createSession] PTY spawn failed (winpty):`, errorMessage);
return null;
}
} else {
logger.error(`[createSession] PTY spawn failed:`, errorMessage);
return null;
}
}
const session: TerminalSession = {
id,
@@ -392,7 +462,11 @@ export class TerminalService extends EventEmitter {
// Handle exit
ptyProcess.onExit(({ exitCode }) => {
logger.info(`Session ${id} exited with code ${exitCode}`);
const exitMessage =
exitCode === undefined || exitCode === null
? 'Session terminated'
: `Session exited with code ${exitCode}`;
logger.info(`${exitMessage} (${id})`);
this.sessions.delete(id);
this.exitCallbacks.forEach((cb) => cb(id, exitCode));
this.emit('exit', id, exitCode);
@@ -477,8 +551,9 @@ export class TerminalService extends EventEmitter {
}
// First try graceful SIGTERM to allow process cleanup
// On Windows, killPtyProcess calls kill() without signal since Windows doesn't support Unix signals
logger.info(`Session ${sessionId} sending SIGTERM`);
session.pty.kill('SIGTERM');
this.killPtyProcess(session.pty, 'SIGTERM');
// Schedule SIGKILL fallback if process doesn't exit gracefully
// The onExit handler will remove session from map when it actually exits
@@ -486,7 +561,7 @@ export class TerminalService extends EventEmitter {
if (this.sessions.has(sessionId)) {
logger.info(`Session ${sessionId} still alive after SIGTERM, sending SIGKILL`);
try {
session.pty.kill('SIGKILL');
this.killPtyProcess(session.pty, 'SIGKILL');
} catch {
// Process may have already exited
}
@@ -588,7 +663,8 @@ export class TerminalService extends EventEmitter {
if (session.flushTimeout) {
clearTimeout(session.flushTimeout);
}
session.pty.kill();
// Use platform-specific kill to ensure proper termination on Windows
this.killPtyProcess(session.pty);
} catch {
// Ignore errors during cleanup
}

View File

@@ -37,7 +37,7 @@ describe('model-resolver.ts', () => {
const result = resolveModelString('opus');
expect(result).toBe('claude-opus-4-5-20251101');
expect(consoleSpy.log).toHaveBeenCalledWith(
expect.stringContaining('Resolved Claude model alias: "opus"')
expect.stringContaining('Migrated legacy ID: "opus" -> "claude-opus"')
);
});

View File

@@ -121,7 +121,7 @@ describe('worktree-metadata.ts', () => {
number: 123,
url: 'https://github.com/owner/repo/pull/123',
title: 'Test PR',
state: 'open',
state: 'OPEN',
createdAt: new Date().toISOString(),
},
};
@@ -158,7 +158,7 @@ describe('worktree-metadata.ts', () => {
number: 456,
url: 'https://github.com/owner/repo/pull/456',
title: 'Updated PR',
state: 'closed',
state: 'CLOSED',
createdAt: new Date().toISOString(),
},
};
@@ -177,7 +177,7 @@ describe('worktree-metadata.ts', () => {
number: 789,
url: 'https://github.com/owner/repo/pull/789',
title: 'New PR',
state: 'open',
state: 'OPEN',
createdAt: new Date().toISOString(),
};
@@ -201,7 +201,7 @@ describe('worktree-metadata.ts', () => {
number: 999,
url: 'https://github.com/owner/repo/pull/999',
title: 'Updated PR',
state: 'merged',
state: 'MERGED',
createdAt: new Date().toISOString(),
};
@@ -224,7 +224,7 @@ describe('worktree-metadata.ts', () => {
number: 111,
url: 'https://github.com/owner/repo/pull/111',
title: 'PR',
state: 'open',
state: 'OPEN',
createdAt: new Date().toISOString(),
};
@@ -259,7 +259,7 @@ describe('worktree-metadata.ts', () => {
number: 222,
url: 'https://github.com/owner/repo/pull/222',
title: 'Has PR',
state: 'open',
state: 'OPEN',
createdAt: new Date().toISOString(),
};
@@ -297,7 +297,7 @@ describe('worktree-metadata.ts', () => {
number: 333,
url: 'https://github.com/owner/repo/pull/333',
title: 'PR 3',
state: 'open',
state: 'OPEN',
createdAt: new Date().toISOString(),
},
};

File diff suppressed because it is too large Load Diff

View File

@@ -286,6 +286,7 @@ describe('claude-provider.ts', () => {
const generator = provider.executeQuery({
prompt: 'Test',
model: 'claude-opus-4-5-20251101',
cwd: '/test',
});
@@ -312,6 +313,7 @@ describe('claude-provider.ts', () => {
const generator = provider.executeQuery({
prompt: 'Test',
model: 'claude-opus-4-5-20251101',
cwd: '/test',
});
@@ -339,6 +341,7 @@ describe('claude-provider.ts', () => {
const generator = provider.executeQuery({
prompt: 'Test',
model: 'claude-opus-4-5-20251101',
cwd: '/test',
});

View File

@@ -11,6 +11,11 @@ import {
getCodexConfigDir,
getCodexAuthIndicators,
} from '@automaker/platform';
import {
calculateReasoningTimeout,
REASONING_TIMEOUT_MULTIPLIERS,
DEFAULT_TIMEOUT_MS,
} from '@automaker/types';
const OPENAI_API_KEY_ENV = 'OPENAI_API_KEY';
const originalOpenAIKey = process.env[OPENAI_API_KEY_ENV];
@@ -289,5 +294,121 @@ describe('codex-provider.ts', () => {
expect(codexRunMock).not.toHaveBeenCalled();
expect(spawnJSONLProcess).toHaveBeenCalled();
});
it('passes extended timeout for high reasoning effort', async () => {
vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})());
await collectAsyncGenerator(
provider.executeQuery({
prompt: 'Complex reasoning task',
model: 'gpt-5.1-codex-max',
cwd: '/tmp',
reasoningEffort: 'high',
})
);
const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0];
// High reasoning effort should have 3x the default timeout (90000ms)
expect(call.timeout).toBe(DEFAULT_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.high);
});
it('passes extended timeout for xhigh reasoning effort', async () => {
vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})());
await collectAsyncGenerator(
provider.executeQuery({
prompt: 'Very complex reasoning task',
model: 'gpt-5.1-codex-max',
cwd: '/tmp',
reasoningEffort: 'xhigh',
})
);
const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0];
// xhigh reasoning effort should have 4x the default timeout (120000ms)
expect(call.timeout).toBe(DEFAULT_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.xhigh);
});
it('uses default timeout when no reasoning effort is specified', async () => {
vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})());
await collectAsyncGenerator(
provider.executeQuery({
prompt: 'Simple task',
model: 'gpt-5.2',
cwd: '/tmp',
})
);
const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0];
// No reasoning effort should use the default timeout
expect(call.timeout).toBe(DEFAULT_TIMEOUT_MS);
});
});
describe('calculateReasoningTimeout', () => {
it('returns default timeout when no reasoning effort is specified', () => {
expect(calculateReasoningTimeout()).toBe(DEFAULT_TIMEOUT_MS);
expect(calculateReasoningTimeout(undefined)).toBe(DEFAULT_TIMEOUT_MS);
});
it('returns default timeout for none reasoning effort', () => {
expect(calculateReasoningTimeout('none')).toBe(DEFAULT_TIMEOUT_MS);
});
it('applies correct multiplier for minimal reasoning effort', () => {
const expected = Math.round(DEFAULT_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.minimal);
expect(calculateReasoningTimeout('minimal')).toBe(expected);
});
it('applies correct multiplier for low reasoning effort', () => {
const expected = Math.round(DEFAULT_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.low);
expect(calculateReasoningTimeout('low')).toBe(expected);
});
it('applies correct multiplier for medium reasoning effort', () => {
const expected = Math.round(DEFAULT_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.medium);
expect(calculateReasoningTimeout('medium')).toBe(expected);
});
it('applies correct multiplier for high reasoning effort', () => {
const expected = Math.round(DEFAULT_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.high);
expect(calculateReasoningTimeout('high')).toBe(expected);
});
it('applies correct multiplier for xhigh reasoning effort', () => {
const expected = Math.round(DEFAULT_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.xhigh);
expect(calculateReasoningTimeout('xhigh')).toBe(expected);
});
it('uses custom base timeout when provided', () => {
const customBase = 60000;
expect(calculateReasoningTimeout('high', customBase)).toBe(
Math.round(customBase * REASONING_TIMEOUT_MULTIPLIERS.high)
);
});
it('falls back to 1.0 multiplier for invalid reasoning effort', () => {
// Test that invalid values fallback gracefully to default multiplier
// This tests the defensive ?? 1.0 in calculateReasoningTimeout
const invalidEffort = 'invalid_effort' as never;
expect(calculateReasoningTimeout(invalidEffort)).toBe(DEFAULT_TIMEOUT_MS);
});
it('produces expected absolute timeout values', () => {
// Verify the actual timeout values that will be used:
// none: 30000ms (30s)
// minimal: 36000ms (36s)
// low: 45000ms (45s)
// medium: 60000ms (1m)
// high: 90000ms (1m 30s)
// xhigh: 120000ms (2m)
expect(calculateReasoningTimeout('none')).toBe(30000);
expect(calculateReasoningTimeout('minimal')).toBe(36000);
expect(calculateReasoningTimeout('low')).toBe(45000);
expect(calculateReasoningTimeout('medium')).toBe(60000);
expect(calculateReasoningTimeout('high')).toBe(90000);
expect(calculateReasoningTimeout('xhigh')).toBe(120000);
});
});
});

View File

@@ -50,8 +50,8 @@ describe('cursor-config-manager.ts', () => {
manager = new CursorConfigManager(testProjectPath);
const config = manager.getConfig();
expect(config.defaultModel).toBe('auto');
expect(config.models).toContain('auto');
expect(config.defaultModel).toBe('cursor-auto');
expect(config.models).toContain('cursor-auto');
});
it('should use default config if file read fails', () => {
@@ -62,7 +62,7 @@ describe('cursor-config-manager.ts', () => {
manager = new CursorConfigManager(testProjectPath);
expect(manager.getDefaultModel()).toBe('auto');
expect(manager.getDefaultModel()).toBe('cursor-auto');
});
it('should use default config if JSON parse fails', () => {
@@ -71,7 +71,7 @@ describe('cursor-config-manager.ts', () => {
manager = new CursorConfigManager(testProjectPath);
expect(manager.getDefaultModel()).toBe('auto');
expect(manager.getDefaultModel()).toBe('cursor-auto');
});
});
@@ -93,7 +93,7 @@ describe('cursor-config-manager.ts', () => {
});
it('should return default model', () => {
expect(manager.getDefaultModel()).toBe('auto');
expect(manager.getDefaultModel()).toBe('cursor-auto');
});
it('should set and persist default model', () => {
@@ -103,13 +103,13 @@ describe('cursor-config-manager.ts', () => {
expect(fs.writeFileSync).toHaveBeenCalled();
});
it('should return auto if defaultModel is undefined', () => {
it('should return cursor-auto if defaultModel is undefined', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ models: ['auto'] }));
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ models: ['cursor-auto'] }));
manager = new CursorConfigManager(testProjectPath);
expect(manager.getDefaultModel()).toBe('auto');
expect(manager.getDefaultModel()).toBe('cursor-auto');
});
});
@@ -121,7 +121,7 @@ describe('cursor-config-manager.ts', () => {
it('should return enabled models', () => {
const models = manager.getEnabledModels();
expect(Array.isArray(models)).toBe(true);
expect(models).toContain('auto');
expect(models).toContain('cursor-auto');
});
it('should set enabled models', () => {
@@ -131,13 +131,13 @@ describe('cursor-config-manager.ts', () => {
expect(fs.writeFileSync).toHaveBeenCalled();
});
it('should return [auto] if models is undefined', () => {
it('should return [cursor-auto] if models is undefined', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ defaultModel: 'auto' }));
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ defaultModel: 'cursor-auto' }));
manager = new CursorConfigManager(testProjectPath);
expect(manager.getEnabledModels()).toEqual(['auto']);
expect(manager.getEnabledModels()).toEqual(['cursor-auto']);
});
});
@@ -146,8 +146,8 @@ describe('cursor-config-manager.ts', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(
JSON.stringify({
defaultModel: 'auto',
models: ['auto'],
defaultModel: 'cursor-auto',
models: ['cursor-auto'],
})
);
manager = new CursorConfigManager(testProjectPath);
@@ -161,14 +161,14 @@ describe('cursor-config-manager.ts', () => {
});
it('should not add duplicate models', () => {
manager.addModel('auto');
manager.addModel('cursor-auto');
// Should not save if model already exists
expect(fs.writeFileSync).not.toHaveBeenCalled();
});
it('should initialize models array if undefined', () => {
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ defaultModel: 'auto' }));
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ defaultModel: 'cursor-auto' }));
manager = new CursorConfigManager(testProjectPath);
manager.addModel('claude-3-5-sonnet');
@@ -293,7 +293,7 @@ describe('cursor-config-manager.ts', () => {
it('should reset to default values', () => {
manager.reset();
expect(manager.getDefaultModel()).toBe('auto');
expect(manager.getDefaultModel()).toBe('cursor-auto');
expect(manager.getMcpServers()).toEqual([]);
expect(manager.getRules()).toEqual([]);
expect(fs.writeFileSync).toHaveBeenCalled();

View File

@@ -202,8 +202,17 @@ describe('auto-mode-service.ts - Planning Mode', () => {
});
describe('buildFeaturePrompt', () => {
const buildFeaturePrompt = (svc: any, feature: any) => {
return svc.buildFeaturePrompt(feature);
const defaultTaskExecutionPrompts = {
implementationInstructions: 'Test implementation instructions',
playwrightVerificationInstructions: 'Test playwright instructions',
};
const buildFeaturePrompt = (
svc: any,
feature: any,
taskExecutionPrompts = defaultTaskExecutionPrompts
) => {
return svc.buildFeaturePrompt(feature, taskExecutionPrompts);
};
it('should include feature ID and description', () => {
@@ -242,14 +251,15 @@ describe('auto-mode-service.ts - Planning Mode', () => {
expect(result).toContain('/tmp/image2.jpg');
});
it('should include summary tags instruction', () => {
it('should include implementation instructions', () => {
const feature = {
id: 'feat-123',
description: 'Test feature',
};
const result = buildFeaturePrompt(service, feature);
expect(result).toContain('<summary>');
expect(result).toContain('</summary>');
// The prompt should include the implementation instructions passed to it
expect(result).toContain('Test implementation instructions');
expect(result).toContain('Test playwright instructions');
});
});

View File

@@ -91,7 +91,7 @@ describe('claude-usage-service.ts', () => {
it("should use 'where' command on Windows", async () => {
vi.mocked(os.platform).mockReturnValue('win32');
const windowsService = new ClaudeUsageService(); // Create new service after platform mock
const ptyService = new ClaudeUsageService(); // Create new service after platform mock
mockSpawnProcess.on.mockImplementation((event: string, callback: Function) => {
if (event === 'close') {
@@ -100,7 +100,7 @@ describe('claude-usage-service.ts', () => {
return mockSpawnProcess;
});
await windowsService.isAvailable();
await ptyService.isAvailable();
expect(spawn).toHaveBeenCalledWith('where', ['claude']);
});
@@ -403,120 +403,22 @@ Resets Jan 15, 3pm
});
});
describe('executeClaudeUsageCommandMac', () => {
beforeEach(() => {
vi.mocked(os.platform).mockReturnValue('darwin');
vi.spyOn(process, 'env', 'get').mockReturnValue({ HOME: '/Users/testuser' });
});
it('should execute expect script and return output', async () => {
const mockOutput = `
Current session
65% left
Resets in 2h
`;
let stdoutCallback: Function;
let closeCallback: Function;
mockSpawnProcess.stdout = {
on: vi.fn((event: string, callback: Function) => {
if (event === 'data') {
stdoutCallback = callback;
}
}),
};
mockSpawnProcess.stderr = {
on: vi.fn(),
};
mockSpawnProcess.on = vi.fn((event: string, callback: Function) => {
if (event === 'close') {
closeCallback = callback;
}
return mockSpawnProcess;
});
const promise = service.fetchUsageData();
// Simulate stdout data
stdoutCallback!(Buffer.from(mockOutput));
// Simulate successful close
closeCallback!(0);
const result = await promise;
expect(result.sessionPercentage).toBe(35); // 100 - 65
expect(spawn).toHaveBeenCalledWith(
'expect',
expect.arrayContaining(['-c']),
expect.any(Object)
);
});
it('should handle authentication errors', async () => {
const mockOutput = 'token_expired';
let stdoutCallback: Function;
let closeCallback: Function;
mockSpawnProcess.stdout = {
on: vi.fn((event: string, callback: Function) => {
if (event === 'data') {
stdoutCallback = callback;
}
}),
};
mockSpawnProcess.stderr = {
on: vi.fn(),
};
mockSpawnProcess.on = vi.fn((event: string, callback: Function) => {
if (event === 'close') {
closeCallback = callback;
}
return mockSpawnProcess;
});
const promise = service.fetchUsageData();
stdoutCallback!(Buffer.from(mockOutput));
closeCallback!(1);
await expect(promise).rejects.toThrow('Authentication required');
});
it('should handle timeout with no data', async () => {
vi.useFakeTimers();
mockSpawnProcess.stdout = {
on: vi.fn(),
};
mockSpawnProcess.stderr = {
on: vi.fn(),
};
mockSpawnProcess.on = vi.fn(() => mockSpawnProcess);
mockSpawnProcess.kill = vi.fn();
const promise = service.fetchUsageData();
// Advance time past timeout (30 seconds)
vi.advanceTimersByTime(31000);
await expect(promise).rejects.toThrow('Command timed out');
vi.useRealTimers();
// Note: executeClaudeUsageCommandMac tests removed - the service now uses PTY for all platforms
// The executeClaudeUsageCommandMac method exists but is dead code (never called)
describe.skip('executeClaudeUsageCommandMac (deprecated - uses PTY now)', () => {
it('should be skipped - service now uses PTY for all platforms', () => {
expect(true).toBe(true);
});
});
describe('executeClaudeUsageCommandWindows', () => {
describe('executeClaudeUsageCommandPty', () => {
// Note: The service now uses PTY for all platforms, using process.cwd() as the working directory
beforeEach(() => {
vi.mocked(os.platform).mockReturnValue('win32');
vi.mocked(os.homedir).mockReturnValue('C:\\Users\\testuser');
vi.spyOn(process, 'env', 'get').mockReturnValue({ USERPROFILE: 'C:\\Users\\testuser' });
});
it('should use node-pty on Windows and return output', async () => {
const windowsService = new ClaudeUsageService(); // Create new service for Windows platform
it('should use node-pty and return output', async () => {
const ptyService = new ClaudeUsageService();
const mockOutput = `
Current session
65% left
@@ -538,7 +440,7 @@ Resets in 2h
};
vi.mocked(pty.spawn).mockReturnValue(mockPty as any);
const promise = windowsService.fetchUsageData();
const promise = ptyService.fetchUsageData();
// Simulate data
dataCallback!(mockOutput);
@@ -549,16 +451,19 @@ Resets in 2h
const result = await promise;
expect(result.sessionPercentage).toBe(35);
// Service uses process.cwd() for --add-dir
expect(pty.spawn).toHaveBeenCalledWith(
'cmd.exe',
['/c', 'claude', '--add-dir', 'C:\\Users\\testuser'],
expect.any(Object)
['/c', 'claude', '--add-dir', process.cwd()],
expect.objectContaining({
cwd: process.cwd(),
})
);
});
it('should send escape key after seeing usage data', async () => {
vi.useFakeTimers();
const windowsService = new ClaudeUsageService();
const ptyService = new ClaudeUsageService();
const mockOutput = 'Current session\n65% left';
@@ -577,7 +482,7 @@ Resets in 2h
};
vi.mocked(pty.spawn).mockReturnValue(mockPty as any);
const promise = windowsService.fetchUsageData();
const promise = ptyService.fetchUsageData();
// Simulate seeing usage data
dataCallback!(mockOutput);
@@ -594,8 +499,8 @@ Resets in 2h
vi.useRealTimers();
});
it('should handle authentication errors on Windows', async () => {
const windowsService = new ClaudeUsageService();
it('should handle authentication errors', async () => {
const ptyService = new ClaudeUsageService();
let dataCallback: Function | undefined;
let exitCallback: Function | undefined;
@@ -611,18 +516,22 @@ Resets in 2h
};
vi.mocked(pty.spawn).mockReturnValue(mockPty as any);
const promise = windowsService.fetchUsageData();
const promise = ptyService.fetchUsageData();
dataCallback!('authentication_error');
// Send data containing the authentication error pattern the service looks for
dataCallback!('"type":"authentication_error"');
// Trigger the exit handler which checks for auth errors
exitCallback!({ exitCode: 1 });
await expect(promise).rejects.toThrow(
"Claude CLI authentication issue. Please run 'claude logout' and then 'claude login' in your terminal to refresh permissions."
);
});
it('should handle timeout with no data on Windows', async () => {
it('should handle timeout with no data', async () => {
vi.useFakeTimers();
const windowsService = new ClaudeUsageService();
const ptyService = new ClaudeUsageService();
const mockPty = {
onData: vi.fn(),
@@ -633,7 +542,7 @@ Resets in 2h
};
vi.mocked(pty.spawn).mockReturnValue(mockPty as any);
const promise = windowsService.fetchUsageData();
const promise = ptyService.fetchUsageData();
// Advance time past timeout (45 seconds)
vi.advanceTimersByTime(46000);
@@ -648,7 +557,7 @@ Resets in 2h
it('should return data on timeout if data was captured', async () => {
vi.useFakeTimers();
const windowsService = new ClaudeUsageService();
const ptyService = new ClaudeUsageService();
let dataCallback: Function | undefined;
@@ -663,7 +572,7 @@ Resets in 2h
};
vi.mocked(pty.spawn).mockReturnValue(mockPty as any);
const promise = windowsService.fetchUsageData();
const promise = ptyService.fetchUsageData();
// Simulate receiving usage data
dataCallback!('Current session\n65% left\nResets in 2h');
@@ -681,7 +590,9 @@ Resets in 2h
it('should send SIGTERM after ESC if process does not exit', async () => {
vi.useFakeTimers();
const windowsService = new ClaudeUsageService();
// Mock Unix platform to test SIGTERM behavior (Windows calls kill() without signal)
vi.mocked(os.platform).mockReturnValue('darwin');
const ptyService = new ClaudeUsageService();
let dataCallback: Function | undefined;
@@ -696,7 +607,7 @@ Resets in 2h
};
vi.mocked(pty.spawn).mockReturnValue(mockPty as any);
windowsService.fetchUsageData();
ptyService.fetchUsageData();
// Simulate seeing usage data
dataCallback!('Current session\n65% left');

View File

@@ -190,9 +190,10 @@ describe('feature-loader.ts', () => {
const result = await loader.getAll(testProjectPath);
expect(result).toEqual([]);
// With recovery-enabled reads, warnings come from AtomicWriter and FeatureLoader
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringMatching(/WARN.*\[FeatureLoader\]/),
expect.stringContaining('Failed to parse feature.json')
expect.stringMatching(/WARN.*\[AtomicWriter\]/),
expect.stringContaining('unavailable')
);
consoleSpy.mockRestore();
@@ -260,10 +261,13 @@ describe('feature-loader.ts', () => {
expect(result).toBeNull();
});
it('should throw on other errors', async () => {
it('should return null on other errors (with recovery attempt)', async () => {
// With recovery-enabled reads, get() returns null instead of throwing
// because it attempts to recover from backups before giving up
vi.mocked(fs.readFile).mockRejectedValue(new Error('Permission denied'));
await expect(loader.get(testProjectPath, 'feature-123')).rejects.toThrow('Permission denied');
const result = await loader.get(testProjectPath, 'feature-123');
expect(result).toBeNull();
});
});
@@ -442,4 +446,471 @@ describe('feature-loader.ts', () => {
);
});
});
describe('findByTitle', () => {
it('should find feature by exact title match (case-insensitive)', async () => {
vi.mocked(fs.access).mockResolvedValue(undefined);
vi.mocked(fs.readdir).mockResolvedValue([
{ name: 'feature-1', isDirectory: () => true } as any,
{ name: 'feature-2', isDirectory: () => true } as any,
]);
vi.mocked(fs.readFile)
.mockResolvedValueOnce(
JSON.stringify({
id: 'feature-1000-abc',
title: 'Login Feature',
category: 'auth',
description: 'Login implementation',
})
)
.mockResolvedValueOnce(
JSON.stringify({
id: 'feature-2000-def',
title: 'Logout Feature',
category: 'auth',
description: 'Logout implementation',
})
);
const result = await loader.findByTitle(testProjectPath, 'LOGIN FEATURE');
expect(result).not.toBeNull();
expect(result?.id).toBe('feature-1000-abc');
expect(result?.title).toBe('Login Feature');
});
it('should return null when title is not found', async () => {
vi.mocked(fs.access).mockResolvedValue(undefined);
vi.mocked(fs.readdir).mockResolvedValue([
{ name: 'feature-1', isDirectory: () => true } as any,
]);
vi.mocked(fs.readFile).mockResolvedValueOnce(
JSON.stringify({
id: 'feature-1000-abc',
title: 'Login Feature',
category: 'auth',
description: 'Login implementation',
})
);
const result = await loader.findByTitle(testProjectPath, 'Nonexistent Feature');
expect(result).toBeNull();
});
it('should return null for empty or whitespace title', async () => {
const result1 = await loader.findByTitle(testProjectPath, '');
const result2 = await loader.findByTitle(testProjectPath, ' ');
expect(result1).toBeNull();
expect(result2).toBeNull();
});
it('should skip features without titles', async () => {
vi.mocked(fs.access).mockResolvedValue(undefined);
vi.mocked(fs.readdir).mockResolvedValue([
{ name: 'feature-1', isDirectory: () => true } as any,
{ name: 'feature-2', isDirectory: () => true } as any,
]);
vi.mocked(fs.readFile)
.mockResolvedValueOnce(
JSON.stringify({
id: 'feature-1000-abc',
// no title
category: 'auth',
description: 'Login implementation',
})
)
.mockResolvedValueOnce(
JSON.stringify({
id: 'feature-2000-def',
title: 'Login Feature',
category: 'auth',
description: 'Another login',
})
);
const result = await loader.findByTitle(testProjectPath, 'Login Feature');
expect(result).not.toBeNull();
expect(result?.id).toBe('feature-2000-def');
});
});
describe('findDuplicateTitle', () => {
it('should find duplicate title', async () => {
vi.mocked(fs.access).mockResolvedValue(undefined);
vi.mocked(fs.readdir).mockResolvedValue([
{ name: 'feature-1', isDirectory: () => true } as any,
]);
vi.mocked(fs.readFile).mockResolvedValueOnce(
JSON.stringify({
id: 'feature-1000-abc',
title: 'My Feature',
category: 'ui',
description: 'Feature description',
})
);
const result = await loader.findDuplicateTitle(testProjectPath, 'my feature');
expect(result).not.toBeNull();
expect(result?.id).toBe('feature-1000-abc');
});
it('should exclude specified feature ID from duplicate check', async () => {
vi.mocked(fs.access).mockResolvedValue(undefined);
vi.mocked(fs.readdir).mockResolvedValue([
{ name: 'feature-1', isDirectory: () => true } as any,
{ name: 'feature-2', isDirectory: () => true } as any,
]);
vi.mocked(fs.readFile)
.mockResolvedValueOnce(
JSON.stringify({
id: 'feature-1000-abc',
title: 'My Feature',
category: 'ui',
description: 'Feature 1',
})
)
.mockResolvedValueOnce(
JSON.stringify({
id: 'feature-2000-def',
title: 'Other Feature',
category: 'ui',
description: 'Feature 2',
})
);
// Should not find duplicate when excluding the feature that has the title
const result = await loader.findDuplicateTitle(
testProjectPath,
'My Feature',
'feature-1000-abc'
);
expect(result).toBeNull();
});
it('should find duplicate when title exists on different feature', async () => {
vi.mocked(fs.access).mockResolvedValue(undefined);
vi.mocked(fs.readdir).mockResolvedValue([
{ name: 'feature-1', isDirectory: () => true } as any,
{ name: 'feature-2', isDirectory: () => true } as any,
]);
vi.mocked(fs.readFile)
.mockResolvedValueOnce(
JSON.stringify({
id: 'feature-1000-abc',
title: 'My Feature',
category: 'ui',
description: 'Feature 1',
})
)
.mockResolvedValueOnce(
JSON.stringify({
id: 'feature-2000-def',
title: 'Other Feature',
category: 'ui',
description: 'Feature 2',
})
);
// Should find duplicate because feature-1000-abc has the title and we're excluding feature-2000-def
const result = await loader.findDuplicateTitle(
testProjectPath,
'My Feature',
'feature-2000-def'
);
expect(result).not.toBeNull();
expect(result?.id).toBe('feature-1000-abc');
});
it('should return null for empty or whitespace title', async () => {
const result1 = await loader.findDuplicateTitle(testProjectPath, '');
const result2 = await loader.findDuplicateTitle(testProjectPath, ' ');
expect(result1).toBeNull();
expect(result2).toBeNull();
});
it('should handle titles with leading/trailing whitespace', async () => {
vi.mocked(fs.access).mockResolvedValue(undefined);
vi.mocked(fs.readdir).mockResolvedValue([
{ name: 'feature-1', isDirectory: () => true } as any,
]);
vi.mocked(fs.readFile).mockResolvedValueOnce(
JSON.stringify({
id: 'feature-1000-abc',
title: 'My Feature',
category: 'ui',
description: 'Feature description',
})
);
const result = await loader.findDuplicateTitle(testProjectPath, ' My Feature ');
expect(result).not.toBeNull();
expect(result?.id).toBe('feature-1000-abc');
});
});
describe('syncFeatureToAppSpec', () => {
const sampleAppSpec = `<?xml version="1.0" encoding="UTF-8"?>
<project_specification>
<project_name>Test Project</project_name>
<core_capabilities>
<capability>Testing</capability>
</core_capabilities>
<implemented_features>
<feature>
<name>Existing Feature</name>
<description>Already implemented</description>
</feature>
</implemented_features>
</project_specification>`;
const appSpecWithoutFeatures = `<?xml version="1.0" encoding="UTF-8"?>
<project_specification>
<project_name>Test Project</project_name>
<core_capabilities>
<capability>Testing</capability>
</core_capabilities>
</project_specification>`;
it('should add feature to app_spec.txt', async () => {
vi.mocked(fs.readFile).mockResolvedValueOnce(sampleAppSpec);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
const feature = {
id: 'feature-1234-abc',
title: 'New Feature',
category: 'ui',
description: 'A new feature description',
};
const result = await loader.syncFeatureToAppSpec(testProjectPath, feature);
expect(result).toBe(true);
expect(fs.writeFile).toHaveBeenCalledWith(
expect.stringContaining('app_spec.txt'),
expect.stringContaining('New Feature'),
'utf-8'
);
expect(fs.writeFile).toHaveBeenCalledWith(
expect.any(String),
expect.stringContaining('A new feature description'),
'utf-8'
);
});
it('should add feature with file locations', async () => {
vi.mocked(fs.readFile).mockResolvedValueOnce(sampleAppSpec);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
const feature = {
id: 'feature-1234-abc',
title: 'Feature With Locations',
category: 'backend',
description: 'Feature with file locations',
};
const result = await loader.syncFeatureToAppSpec(testProjectPath, feature, [
'src/feature.ts',
'src/utils/helper.ts',
]);
expect(result).toBe(true);
expect(fs.writeFile).toHaveBeenCalledWith(
expect.any(String),
expect.stringContaining('src/feature.ts'),
'utf-8'
);
expect(fs.writeFile).toHaveBeenCalledWith(
expect.any(String),
expect.stringContaining('src/utils/helper.ts'),
'utf-8'
);
});
it('should return false when app_spec.txt does not exist', async () => {
const error: any = new Error('File not found');
error.code = 'ENOENT';
vi.mocked(fs.readFile).mockRejectedValueOnce(error);
const feature = {
id: 'feature-1234-abc',
title: 'New Feature',
category: 'ui',
description: 'A new feature description',
};
const result = await loader.syncFeatureToAppSpec(testProjectPath, feature);
expect(result).toBe(false);
expect(fs.writeFile).not.toHaveBeenCalled();
});
it('should return false when feature already exists (duplicate)', async () => {
vi.mocked(fs.readFile).mockResolvedValueOnce(sampleAppSpec);
const feature = {
id: 'feature-5678-xyz',
title: 'Existing Feature', // Same name as existing feature
category: 'ui',
description: 'Different description',
};
const result = await loader.syncFeatureToAppSpec(testProjectPath, feature);
expect(result).toBe(false);
expect(fs.writeFile).not.toHaveBeenCalled();
});
it('should use feature ID as fallback name when title is missing', async () => {
vi.mocked(fs.readFile).mockResolvedValueOnce(sampleAppSpec);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
const feature = {
id: 'feature-1234-abc',
category: 'ui',
description: 'Feature without title',
// No title property
};
const result = await loader.syncFeatureToAppSpec(testProjectPath, feature);
expect(result).toBe(true);
expect(fs.writeFile).toHaveBeenCalledWith(
expect.any(String),
expect.stringContaining('Feature: feature-1234-abc'),
'utf-8'
);
});
it('should handle app_spec without implemented_features section', async () => {
vi.mocked(fs.readFile).mockResolvedValueOnce(appSpecWithoutFeatures);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
const feature = {
id: 'feature-1234-abc',
title: 'First Feature',
category: 'ui',
description: 'First implemented feature',
};
const result = await loader.syncFeatureToAppSpec(testProjectPath, feature);
expect(result).toBe(true);
expect(fs.writeFile).toHaveBeenCalledWith(
expect.any(String),
expect.stringContaining('<implemented_features>'),
'utf-8'
);
expect(fs.writeFile).toHaveBeenCalledWith(
expect.any(String),
expect.stringContaining('First Feature'),
'utf-8'
);
});
it('should throw on non-ENOENT file read errors', async () => {
const error = new Error('Permission denied');
vi.mocked(fs.readFile).mockRejectedValueOnce(error);
const feature = {
id: 'feature-1234-abc',
title: 'New Feature',
category: 'ui',
description: 'A new feature description',
};
await expect(loader.syncFeatureToAppSpec(testProjectPath, feature)).rejects.toThrow(
'Permission denied'
);
});
it('should preserve existing features when adding a new one', async () => {
vi.mocked(fs.readFile).mockResolvedValueOnce(sampleAppSpec);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
const feature = {
id: 'feature-1234-abc',
title: 'New Feature',
category: 'ui',
description: 'A new feature',
};
await loader.syncFeatureToAppSpec(testProjectPath, feature);
// Verify both old and new features are in the output
expect(fs.writeFile).toHaveBeenCalledWith(
expect.any(String),
expect.stringContaining('Existing Feature'),
'utf-8'
);
expect(fs.writeFile).toHaveBeenCalledWith(
expect.any(String),
expect.stringContaining('New Feature'),
'utf-8'
);
});
it('should escape special characters in feature name and description', async () => {
vi.mocked(fs.readFile).mockResolvedValueOnce(sampleAppSpec);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
const feature = {
id: 'feature-1234-abc',
title: 'Feature with <special> & "chars"',
category: 'ui',
description: 'Description with <tags> & "quotes"',
};
const result = await loader.syncFeatureToAppSpec(testProjectPath, feature);
expect(result).toBe(true);
// The XML should have escaped characters
expect(fs.writeFile).toHaveBeenCalledWith(
expect.any(String),
expect.stringContaining('&lt;special&gt;'),
'utf-8'
);
expect(fs.writeFile).toHaveBeenCalledWith(
expect.any(String),
expect.stringContaining('&amp;'),
'utf-8'
);
});
it('should not add empty file_locations array', async () => {
vi.mocked(fs.readFile).mockResolvedValueOnce(sampleAppSpec);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
const feature = {
id: 'feature-1234-abc',
title: 'Feature Without Locations',
category: 'ui',
description: 'No file locations',
};
await loader.syncFeatureToAppSpec(testProjectPath, feature, []);
// File locations should not be included when array is empty
const writeCall = vi.mocked(fs.writeFile).mock.calls[0];
const writtenContent = writeCall[1] as string;
// Count occurrences of file_locations - should only have the one from Existing Feature if any
// The new feature should not add file_locations
expect(writtenContent).toContain('Feature Without Locations');
});
});
});

View File

@@ -647,9 +647,10 @@ describe('settings-service.ts', () => {
const settings = await settingsService.getGlobalSettings();
// Verify all phase models are now PhaseModelEntry objects
expect(settings.phaseModels.enhancementModel).toEqual({ model: 'sonnet' });
expect(settings.phaseModels.fileDescriptionModel).toEqual({ model: 'haiku' });
expect(settings.phaseModels.specGenerationModel).toEqual({ model: 'opus' });
// Legacy aliases are migrated to canonical IDs
expect(settings.phaseModels.enhancementModel).toEqual({ model: 'claude-sonnet' });
expect(settings.phaseModels.fileDescriptionModel).toEqual({ model: 'claude-haiku' });
expect(settings.phaseModels.specGenerationModel).toEqual({ model: 'claude-opus' });
expect(settings.version).toBe(SETTINGS_VERSION);
});
@@ -675,16 +676,17 @@ describe('settings-service.ts', () => {
const settings = await settingsService.getGlobalSettings();
// Verify PhaseModelEntry objects are preserved with thinkingLevel
// Legacy aliases are migrated to canonical IDs
expect(settings.phaseModels.enhancementModel).toEqual({
model: 'sonnet',
model: 'claude-sonnet',
thinkingLevel: 'high',
});
expect(settings.phaseModels.specGenerationModel).toEqual({
model: 'opus',
model: 'claude-opus',
thinkingLevel: 'ultrathink',
});
expect(settings.phaseModels.backlogPlanningModel).toEqual({
model: 'sonnet',
model: 'claude-sonnet',
thinkingLevel: 'medium',
});
});
@@ -710,15 +712,15 @@ describe('settings-service.ts', () => {
const settings = await settingsService.getGlobalSettings();
// Strings should be converted to objects
expect(settings.phaseModels.enhancementModel).toEqual({ model: 'sonnet' });
expect(settings.phaseModels.imageDescriptionModel).toEqual({ model: 'haiku' });
// Objects should be preserved
// Strings should be converted to objects with canonical IDs
expect(settings.phaseModels.enhancementModel).toEqual({ model: 'claude-sonnet' });
expect(settings.phaseModels.imageDescriptionModel).toEqual({ model: 'claude-haiku' });
// Objects should be preserved with migrated IDs
expect(settings.phaseModels.fileDescriptionModel).toEqual({
model: 'haiku',
model: 'claude-haiku',
thinkingLevel: 'low',
});
expect(settings.phaseModels.validationModel).toEqual({ model: 'opus' });
expect(settings.phaseModels.validationModel).toEqual({ model: 'claude-opus' });
});
it('should migrate legacy enhancementModel/validationModel fields', async () => {
@@ -735,11 +737,11 @@ describe('settings-service.ts', () => {
const settings = await settingsService.getGlobalSettings();
// Legacy fields should be migrated to phaseModels
expect(settings.phaseModels.enhancementModel).toEqual({ model: 'haiku' });
expect(settings.phaseModels.validationModel).toEqual({ model: 'opus' });
// Other fields should use defaults
expect(settings.phaseModels.specGenerationModel).toEqual({ model: 'opus' });
// Legacy fields should be migrated to phaseModels with canonical IDs
expect(settings.phaseModels.enhancementModel).toEqual({ model: 'claude-haiku' });
expect(settings.phaseModels.validationModel).toEqual({ model: 'claude-opus' });
// Other fields should use defaults (canonical IDs)
expect(settings.phaseModels.specGenerationModel).toEqual({ model: 'claude-opus' });
});
it('should use default phase models when none are configured', async () => {
@@ -753,10 +755,10 @@ describe('settings-service.ts', () => {
const settings = await settingsService.getGlobalSettings();
// Should use DEFAULT_PHASE_MODELS
expect(settings.phaseModels.enhancementModel).toEqual({ model: 'sonnet' });
expect(settings.phaseModels.fileDescriptionModel).toEqual({ model: 'haiku' });
expect(settings.phaseModels.specGenerationModel).toEqual({ model: 'opus' });
// Should use DEFAULT_PHASE_MODELS (with canonical IDs)
expect(settings.phaseModels.enhancementModel).toEqual({ model: 'claude-sonnet' });
expect(settings.phaseModels.fileDescriptionModel).toEqual({ model: 'claude-haiku' });
expect(settings.phaseModels.specGenerationModel).toEqual({ model: 'claude-opus' });
});
it('should deep merge phaseModels on update', async () => {
@@ -776,13 +778,13 @@ describe('settings-service.ts', () => {
const settings = await settingsService.getGlobalSettings();
// Both should be preserved
// Both should be preserved (models migrated to canonical format)
expect(settings.phaseModels.enhancementModel).toEqual({
model: 'sonnet',
model: 'claude-sonnet',
thinkingLevel: 'high',
});
expect(settings.phaseModels.specGenerationModel).toEqual({
model: 'opus',
model: 'claude-opus',
thinkingLevel: 'ultrathink',
});
});

View File

@@ -1,6 +1,6 @@
{
"name": "@automaker/ui",
"version": "0.11.0",
"version": "0.12.0",
"description": "An autonomous AI development studio that helps you build software faster using AI-powered agents",
"homepage": "https://github.com/AutoMaker-Org/automaker",
"repository": {
@@ -40,6 +40,7 @@
},
"dependencies": {
"@automaker/dependency-resolver": "1.0.0",
"@automaker/spec-parser": "1.0.0",
"@automaker/types": "1.0.0",
"@codemirror/lang-xml": "6.1.0",
"@codemirror/language": "^6.12.1",
@@ -48,6 +49,22 @@
"@dnd-kit/core": "6.3.1",
"@dnd-kit/sortable": "10.0.0",
"@dnd-kit/utilities": "3.2.2",
"@fontsource/cascadia-code": "^5.2.3",
"@fontsource/fira-code": "^5.2.7",
"@fontsource/ibm-plex-mono": "^5.2.7",
"@fontsource/inconsolata": "^5.2.8",
"@fontsource/inter": "^5.2.8",
"@fontsource/iosevka": "^5.2.5",
"@fontsource/jetbrains-mono": "^5.2.8",
"@fontsource/lato": "^5.2.7",
"@fontsource/montserrat": "^5.2.8",
"@fontsource/open-sans": "^5.2.7",
"@fontsource/poppins": "^5.2.7",
"@fontsource/raleway": "^5.2.8",
"@fontsource/roboto": "^5.2.9",
"@fontsource/source-code-pro": "^5.2.7",
"@fontsource/source-sans-3": "^5.2.9",
"@fontsource/work-sans": "^5.2.8",
"@lezer/highlight": "1.2.3",
"@radix-ui/react-checkbox": "1.3.3",
"@radix-ui/react-collapsible": "1.1.12",
@@ -204,12 +221,34 @@
"arch": [
"x64"
]
},
{
"target": "rpm",
"arch": [
"x64"
]
}
],
"category": "Development",
"icon": "public/logo_larger.png",
"maintainer": "webdevcody@gmail.com",
"executableName": "automaker"
"executableName": "automaker",
"description": "An autonomous AI development studio that helps you build software faster using AI-powered agents",
"synopsis": "AI-powered autonomous development studio"
},
"rpm": {
"depends": [
"gtk3",
"libnotify",
"nss",
"libXScrnSaver",
"libXtst",
"xdg-utils",
"at-spi2-core",
"libuuid"
],
"compression": "xz",
"vendor": "AutoMaker Team"
},
"nsis": {
"oneClick": false,

View File

@@ -8,6 +8,7 @@ import { useCursorStatusInit } from './hooks/use-cursor-status-init';
import { useProviderAuthInit } from './hooks/use-provider-auth-init';
import './styles/global.css';
import './styles/theme-imports';
import './styles/font-imports';
const logger = createLogger('App');

View File

@@ -0,0 +1,67 @@
/* Zed Fonts - https://github.com/zed-industries/zed-fonts */
/* Zed Sans - UI Font */
@font-face {
font-family: 'Zed Sans';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('./zed-sans-extended.ttf') format('truetype');
}
@font-face {
font-family: 'Zed Sans';
font-style: italic;
font-weight: 400;
font-display: swap;
src: url('./zed-sans-extendeditalic.ttf') format('truetype');
}
@font-face {
font-family: 'Zed Sans';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('./zed-sans-extendedbold.ttf') format('truetype');
}
@font-face {
font-family: 'Zed Sans';
font-style: italic;
font-weight: 700;
font-display: swap;
src: url('./zed-sans-extendedbolditalic.ttf') format('truetype');
}
/* Zed Mono - Code Font */
@font-face {
font-family: 'Zed Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('./zed-mono-extended.ttf') format('truetype');
}
@font-face {
font-family: 'Zed Mono';
font-style: italic;
font-weight: 400;
font-display: swap;
src: url('./zed-mono-extendeditalic.ttf') format('truetype');
}
@font-face {
font-family: 'Zed Mono';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('./zed-mono-extendedbold.ttf') format('truetype');
}
@font-face {
font-family: 'Zed Mono';
font-style: italic;
font-weight: 700;
font-display: swap;
src: url('./zed-mono-extendedbolditalic.ttf') format('truetype');
}

Binary file not shown.

Binary file not shown.

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