209 Commits

Author SHA1 Message Date
Web Dev Cody
d37ced1c6e Merge pull request #841 from AutoMaker-Org/v1.0.0rc
V1.0.0rc
2026-03-15 12:58:24 -04:00
gsxdsm
0311130a8a fix: Update @github/copilot-sdk version and enhance version utility to locate package.json (#840)
- Changed @github/copilot-sdk dependency from "^0.1.16" to "0.1.16" in package.json and package-lock.json.
- Improved version utility in version.ts to check multiple candidate paths for package.json, ensuring better reliability in locating the file.
2026-03-12 19:30:17 -07:00
gsxdsm
7be8163b84 Preserve pipeline work by using waiting_approval on post-completion errors (#836)
* Changes from fix/pipeline-not-finishing

* fix: Prevent overwriting merge_conflict status in pipeline error handlers

* fix: Handle feature loading failures gracefully in error recovery

* ```
fix: Add logging when feature reload fails during status update
```
2026-03-04 20:22:44 -08:00
gsxdsm
26b73df097 Fix feature deep link with project path handling (#834)
* Changes from fix/feature-deeplink-worktree

* Update apps/ui/src/components/views/board-view.tsx

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-03-04 10:13:24 -08:00
gsxdsm
20e7c74b17 Fix event endpoint persistence (#831)
* Changes from fix/event-hook-endpoint

* fix: Allow empty eventHooks/ntfyEndpoints to reconcile from server

Remove the `length > 0` guards in fast-hydrate reconciliation that
prevented intentional empty-array clears from syncing across clients.
Server-side wipe protection (`__allowEmpty*` escape hatches) already
ensures empty arrays in the server are intentional.

Addresses PR #831 review feedback from CodeRabbit and Gemini.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 20:33:12 -08:00
gsxdsm
dd7108a7a0 Fixes critical React crash on the Kanban board view (#830)
* Changes from fix/board-react-crash

* fix: Prevent cascading re-renders and crashes from high-frequency WS events
2026-03-03 19:23:44 -08:00
gsxdsm
ae48065820 Fix dev server hang by reducing log spam and event frequency (#828)
* Changes from fix/dev-server-hang

* fix: Address PR #828 review feedback

- Reset RAF buffer on context changes (worktree switch, dev-server restart)
  to prevent stale output from flushing into new sessions
- Fix high-frequency WebSocket filter to catch auto-mode:event wrapping
  (auto_mode_progress is wrapped in auto-mode:event) and add feature:progress
- Reorder Vite aliases so explicit jsx-runtime entries aren't shadowed by
  the broad /^react(\/|$)/ regex (Vite uses first-match-wins)

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

* feat: Batch dev server logs and fix React module resolution order

* feat: Add fallback timer for flushing dev server logs in background tabs

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 23:52:44 -08:00
gsxdsm
b2915f4de1 refactor: Simplify click URL resolution logic 2026-03-02 21:36:33 -08:00
gsxdsm
cf3d312eef fix: Remove overly restrictive pattern from summary extraction regex 2026-03-02 21:36:33 -08:00
gsxdsm
341a6534e6 refactor: extract shared isBacklogLikeStatus helper and improve comments
Address PR #825 review feedback:
- Extract duplicated backlog-like status check into shared helper in constants.ts
- Improve spec-parser regex comment to clarify subsection preservation
- Add file path reference in row-actions.tsx comment for traceability

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 21:36:33 -08:00
gsxdsm
4a128efbf4 Changes from fix/board-crash-new-feat 2026-03-02 21:36:33 -08:00
gsxdsm
54d69e907b fix: E2E test stability and UI performance improvements (#823) 2026-03-02 18:46:28 -08:00
gsxdsm
1c3d6434a8 fix: reduce excessive POST /api/auto-mode/context-exists requests
Two changes:
1. Server: Skip morgan logging for context-exists endpoint (like health check)
2. UI: Use a stable fingerprint (feature IDs + statuses) instead of the
   unstable features array reference as the useEffect dependency. This
   prevents re-checking context for all features on every React Query
   refetch when the actual feature set hasn't changed.
2026-03-02 11:14:46 -08:00
gsxdsm
8218c48e67 fix: use object destructuring in Playwright beforeEach callback
Playwright requires the first argument to use object destructuring pattern.
Changed `_` to `{}` in feature-deep-link.spec.ts beforeEach.
2026-03-02 07:50:05 -08:00
gsxdsm
59b100b5cc feat: Add conflict source branch tracking and fix auto-mode subscription cascade 2026-03-02 07:43:00 -08:00
gsxdsm
c11f390764 feat: Add conflict source branch detection and fix re-render cascade in BoardView 2026-03-02 07:20:11 -08:00
gsxdsm
33a2e04bf0 feat: Add settingsService integration for feature defaults and improve worktree handling 2026-03-02 03:28:37 -08:00
gsxdsm
34161ccc08 Changes from fix/bug-fixes-1rc 2026-03-01 21:59:02 -08:00
gsxdsm
57bcb2802d Improve auto-loop event emission and add ntfy notifications (#821) 2026-03-01 00:12:22 -08:00
gsxdsm
63b0a4fb38 Fix orphaned features when deleting worktrees (#820)
* Changes from fix/orphaned-features

* fix: Handle feature migration failures and improve UI accessibility

* feat: Add event emission for worktree deletion and feature migration

* fix: Handle OpenCode model errors and prevent duplicate model IDs

* feat: Add summary dialog and async verify with loading state

* fix: Add type attributes to buttons and improve OpenCode model selection

* fix: Add null checks for onVerify callback and opencode model selection
2026-02-28 15:42:10 -08:00
gsxdsm
1c0e460dd1 Add orphaned features management routes and UI integration (#819)
* test(copilot): add edge case test for error with code field

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Changes from fix/bug-fixes-1-0

* refactor(auto-mode): enhance orphaned feature detection and improve project initialization

- Updated detectOrphanedFeatures method to accept preloaded features, reducing redundant disk reads.
- Improved project initialization by creating required directories and files in parallel for better performance.
- Adjusted planning mode handling in UI components to clarify approval requirements for different modes.
- Added refresh functionality for file editor tabs to ensure content consistency with disk state.

These changes enhance performance, maintainability, and user experience across the application.

* feat(orphaned-features): add orphaned features management routes and UI integration

- Introduced new routes for managing orphaned features, including listing, resolving, and bulk resolving.
- Updated the UI to include an Orphaned Features section in project settings and navigation.
- Enhanced the execution service to support new orphaned feature functionalities.

These changes improve the application's capability to handle orphaned features effectively, enhancing user experience and project management.

* fix: Normalize line endings and resolve stale dirty states in file editor

* chore: Update .gitignore and enhance orphaned feature handling

- Added a blank line in .gitignore for better readability.
- Introduced a hash to worktree paths in orphaned feature resolution to prevent conflicts.
- Added validation for target branch existence during orphaned feature resolution.
- Improved prompt formatting in execution service for clarity.
- Enhanced error handling in project selector for project initialization failures.
- Refactored orphaned features section to improve state management and UI responsiveness.

These changes improve code maintainability and user experience when managing orphaned features.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-27 22:14:41 -08:00
gsxdsm
0196911d59 Bug fixes and stability improvements (#815)
* fix(copilot): correct tool.execution_complete event handling

The CopilotProvider was using incorrect event type and data structure
for tool execution completion events from the @github/copilot-sdk,
causing tool call outputs to be empty.

Changes:
- Update event type from 'tool.execution_end' to 'tool.execution_complete'
- Fix data structure to use nested result.content instead of flat result
- Fix error structure to use error.message instead of flat error
- Add success field to match SDK event structure
- Add tests for empty and missing result handling

This aligns with the official @github/copilot-sdk v0.1.16 types
defined in session-events.d.ts.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* test(copilot): add edge case test for error with code field

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* refactor(copilot): improve error handling and code quality

Code review improvements:
- Extract magic string '[ERROR]' to TOOL_ERROR_PREFIX constant
- Add null-safe error handling with direct error variable assignment
- Include error codes in error messages for better debugging
- Add JSDoc documentation for tool.execution_complete handler
- Update tests to verify error codes are displayed
- Add missing tool_use_id assertion in error test

These changes improve:
- Code maintainability (no magic strings)
- Debugging experience (error codes now visible)
- Type safety (explicit null checks)
- Test coverage (verify error code formatting)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Changes from fix/bug-fixes-1-0

* test(copilot): add edge case test for error with code field

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Changes from fix/bug-fixes-1-0

* fix: Handle detached HEAD state in worktree discovery and recovery

* fix: Remove unused isDevServerStarting prop and md: breakpoint classes

* fix: Add missing dependency and sanitize persisted cache data

* feat: Ensure NODE_ENV is set to test in vitest configs

* feat: Configure Playwright to run only E2E tests

* fix: Improve PR tracking and dev server lifecycle management

* feat: Add settings-based defaults for planning mode, model config, and custom providers. Fixes #816

* feat: Add worktree and branch selector to graph view

* fix: Add timeout and error handling for worktree HEAD ref resolution

* fix: use absolute icon path and place icon outside asar on Linux

The hicolor icon theme index only lists sizes up to 512x512, so an icon
installed only at 1024x1024 is invisible to GNOME/KDE's theme resolver,
causing both the app launcher and taskbar to show a generic icon.
Additionally, BrowserWindow.icon cannot be read by the window manager
when the file is inside app.asar.

- extraResources: copy logo_larger.png to resources/ (outside asar) so
  it lands at /opt/Automaker/resources/logo_larger.png on install
- linux.desktop.Icon: set to the absolute resources path, bypassing the
  hicolor theme lookup and its size constraints entirely
- icon-manager.ts: on Linux production use process.resourcesPath so
  BrowserWindow receives a real filesystem path the WM can read directly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: use linux.desktop.entry for custom desktop Icon field

electron-builder v26 rejects arbitrary keys in linux.desktop — the
correct schema wraps custom .desktop overrides inside desktop.entry.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: set desktop name on Linux so taskbar uses the correct app icon

Without app.setDesktopName(), the window manager cannot associate the
running Electron process with automaker.desktop. GNOME/KDE fall back to
_NET_WM_ICON which defaults to Electron's own bundled icon.

Calling app.setDesktopName('automaker.desktop') before any window is
created sets the _GTK_APPLICATION_ID hint and XDG app_id so the WM
picks up the desktop entry's Icon for the taskbar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix: memory and context views mobile friendly (#818)

* Changes from fix/memory-and-context-mobile-friendly

* fix: Improve file extension detection and add path traversal protection

* refactor: Extract file extension utilities and add path traversal guards

Code review improvements:
- Extract isMarkdownFilename and isImageFilename to shared image-utils.ts
- Remove duplicated code from context-view.tsx and memory-view.tsx
- Add path traversal guard for context fixture utilities (matching memory)
- Add 7 new tests for context fixture path traversal protection
- Total 61 tests pass

Addresses code review feedback from PR #813

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

* test: Add e2e tests for profiles crud and board background persistence

* Update apps/ui/playwright.config.ts

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* fix: Add robust test navigation handling and file filtering

* fix: Format NODE_OPTIONS configuration on single line

* test: Update profiles and board background persistence tests

* test: Replace iPhone 13 Pro with Pixel 5 for mobile test consistency

* Update apps/ui/src/components/views/context-view.tsx

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* chore: Remove test project directory

* feat: Filter context files by type and improve mobile menu visibility

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* fix: Improve test reliability and localhost handling

* chore: Use explicit TEST_USE_EXTERNAL_BACKEND env var for server cleanup

* feat: Add E2E/CI mock mode for provider factory and auth verification

* feat: Add remoteBranch parameter to pull and rebase operations

* chore: Enhance E2E testing setup with worker isolation and auth state management

- Updated .gitignore to include worker-specific test fixtures.
- Modified e2e-tests.yml to implement test sharding for improved CI performance.
- Refactored global setup to authenticate once and save session state for reuse across tests.
- Introduced worker-isolated fixture paths to prevent conflicts during parallel test execution.
- Improved test navigation and loading handling for better reliability.
- Updated various test files to utilize new auth state management and fixture paths.

* fix: Update Playwright configuration and improve test reliability

- Increased the number of workers in Playwright configuration for better parallelism in CI environments.
- Enhanced the board background persistence test to ensure dropdown stability by waiting for the list to populate before interaction, improving test reliability.

* chore: Simplify E2E test configuration and enhance mock implementations

- Updated e2e-tests.yml to run tests in a single shard for streamlined CI execution.
- Enhanced unit tests for worktree list handling by introducing a mock for execGitCommand, improving test reliability and coverage.
- Refactored setup functions to better manage command mocks for git operations in tests.
- Improved error handling in mkdirSafe function to account for undefined stats in certain environments.

* refactor: Improve test configurations and enhance error handling

- Updated Playwright configuration to clear VITE_SERVER_URL, ensuring the frontend uses the Vite proxy and preventing cookie domain mismatches.
- Enhanced MergeRebaseDialog logic to normalize selectedBranch for better handling of various ref formats.
- Improved global setup with a more robust backend health check, throwing an error if the backend is not healthy after retries.
- Refactored project creation tests to handle file existence checks more reliably.
- Added error handling for missing E2E source fixtures to guide setup process.
- Enhanced memory navigation to handle sandbox dialog visibility more effectively.

* refactor: Enhance Git command execution and improve test configurations

- Updated Git command execution to merge environment paths correctly, ensuring proper command execution context.
- Refactored the Git initialization process to handle errors more gracefully and ensure user configuration is set before creating the initial commit.
- Improved test configurations by updating Playwright test identifiers for better clarity and consistency across different project states.
- Enhanced cleanup functions in tests to handle directory removal more robustly, preventing errors during test execution.

* fix: Resolve React hooks errors from duplicate instances in dependency tree

* style: Format alias configuration for improved readability

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: DhanushSantosh <dhanushsantoshs05@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-02-27 17:03:29 -08:00
gsxdsm
70d400793b Fix: memory and context views mobile friendly (#818)
* Changes from fix/memory-and-context-mobile-friendly

* fix: Improve file extension detection and add path traversal protection

* refactor: Extract file extension utilities and add path traversal guards

Code review improvements:
- Extract isMarkdownFilename and isImageFilename to shared image-utils.ts
- Remove duplicated code from context-view.tsx and memory-view.tsx
- Add path traversal guard for context fixture utilities (matching memory)
- Add 7 new tests for context fixture path traversal protection
- Total 61 tests pass

Addresses code review feedback from PR #813

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

* test: Add e2e tests for profiles crud and board background persistence

* Update apps/ui/playwright.config.ts

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* fix: Add robust test navigation handling and file filtering

* fix: Format NODE_OPTIONS configuration on single line

* test: Update profiles and board background persistence tests

* test: Replace iPhone 13 Pro with Pixel 5 for mobile test consistency

* Update apps/ui/src/components/views/context-view.tsx

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* chore: Remove test project directory

* feat: Filter context files by type and improve mobile menu visibility

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-02-26 08:37:33 -08:00
DhanushSantosh
dd7654c254 fix: set desktop name on Linux so taskbar uses the correct app icon
Without app.setDesktopName(), the window manager cannot associate the
running Electron process with automaker.desktop. GNOME/KDE fall back to
_NET_WM_ICON which defaults to Electron's own bundled icon.

Calling app.setDesktopName('automaker.desktop') before any window is
created sets the _GTK_APPLICATION_ID hint and XDG app_id so the WM
picks up the desktop entry's Icon for the taskbar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 18:14:08 +05:30
DhanushSantosh
6a824a9ff0 fix: use linux.desktop.entry for custom desktop Icon field
electron-builder v26 rejects arbitrary keys in linux.desktop — the
correct schema wraps custom .desktop overrides inside desktop.entry.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 17:53:15 +05:30
DhanushSantosh
5e15f4120c Merge remote-tracking branch 'upstream/v1.0.0rc' into patchcraft 2026-02-26 17:49:45 +05:30
DhanushSantosh
82e9396cb8 fix: use absolute icon path and place icon outside asar on Linux
The hicolor icon theme index only lists sizes up to 512x512, so an icon
installed only at 1024x1024 is invisible to GNOME/KDE's theme resolver,
causing both the app launcher and taskbar to show a generic icon.
Additionally, BrowserWindow.icon cannot be read by the window manager
when the file is inside app.asar.

- extraResources: copy logo_larger.png to resources/ (outside asar) so
  it lands at /opt/Automaker/resources/logo_larger.png on install
- linux.desktop.Icon: set to the absolute resources path, bypassing the
  hicolor theme lookup and its size constraints entirely
- icon-manager.ts: on Linux production use process.resourcesPath so
  BrowserWindow receives a real filesystem path the WM can read directly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 17:49:26 +05:30
gsxdsm
9747faf1b9 Fix agent output summary for pipeline steps (#812)
* Changes from fix/agent-output-summary-for-pipeline-steps

* feat: Optimize pipeline summary extraction and fix regex vulnerability

* fix: Use fallback summary for pipeline steps when extraction fails

* fix: Strip follow-up session scaffold from pipeline step fallback summaries
2026-02-25 22:13:38 -08:00
DhanushSantosh
70c9fd77f6 fix: correct production icon path and refresh icon cache on RPM install
- icon-manager.ts: fix production path from '../dist/public' to '../dist'
  Vite copies public/ assets to the root of dist/, not dist/public/, so
  the old path caused electronAppExists() to return null in packaged
  builds — falling through to Electron's default icon in the taskbar
- rpm-after-install.sh: add gtk-update-icon-cache and
  update-desktop-database so GNOME/KDE picks up the installed icon and
  desktop entry immediately without a session restart

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 11:20:03 +05:30
DhanushSantosh
46ee34d499 fix: resolve desktop entry launch failure and hide Electron menu bar on Linux
- Add --ozone-platform-hint=auto before app.whenReady() so Electron
  auto-detects X11 vs Wayland when launched from a desktop entry where
  the display protocol is not guaranteed (fixes immediate crash on
  Fedora/GNOME Wayland sessions)
- Add autoHideMenuBar: true to BrowserWindow so the default Chromium
  File/Edit/View/Help menu bar is hidden by default (still accessible
  via Alt); removes visible Electron significance from the packaged app
- Add scripts/rpm-after-install.sh and wire it via afterInstall in the
  electron-builder RPM config to chmod 4755 chrome-sandbox, ensuring
  the setuid sandbox works on kernels with restricted user namespaces

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 10:53:58 +05:30
Web Dev Cody
6408f514a4 Merge pull request #810 from AutoMaker-Org/v0.15.0rc
V0.15.0rc
2026-02-25 08:34:55 -05:00
Patrick Patel
6b97219f55 fix: Add dev-server:url-detected to EventType (#808)
* fix: Add dev-server:url-detected to EventType

The dev-server-service emits this event when a dev server URL is
detected from output; the type was missing from the EventType union
and caused a TypeScript build error.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Update libs/types/src/event.ts

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: gsxdsm <gsxdsm@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-02-24 20:25:38 -08:00
Patrick Patel
09a4d3f15a fix: Resolve Claude-compatible provider for backlog plan when client sends model (#809)
When the Plan dialog sends a model (e.g. MiniMax-M2.1 from phase
settings), the server now:

- Calls getProviderByModelId() so the correct provider config
  (baseUrl, credentials) is used for backlog plan generation.
- Falls back to getPhaseModelWithOverrides('backlogPlanningModel')
  when model lookup finds no provider, so the phase's provider is
  used when the model matches.
- Uses a plain system prompt instead of the claude_code preset when
  a Claude-compatible provider is set; the preset is for native
  Claude CLI and can break requests to MiniMax/GLM APIs.

Previously the request was sent to the default Anthropic endpoint
and/or used the preset, causing plan generation to fail for
MiniMax/GLM users.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 20:21:05 -08:00
gsxdsm
51e9a23ba1 Fix agent output validation to prevent false verified status (#807)
* Changes from fix/cursor-fix

* feat: Enhance provider error messages with diagnostic context, address test failure, fix port change, move playwright tests to different port

* Update apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* ci: Update test server port from 3008 to 3108 and add environment configuration

* fix: Correct typo in health endpoint URL and standardize port env vars

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-02-24 20:18:40 -08:00
gsxdsm
0330c70261 Feature: worktree view customization and stability fixes (#805)
* Changes from feature/worktree-view-customization

* Feature: Git sync, set-tracking, and push divergence handling (#796)

* Add quick-add feature with improved workflows (#802)

* Changes from feature/quick-add

* feat: Clarify system prompt and improve error handling across services. Address PR Feedback

* feat: Improve PR description parsing and refactor event handling

* feat: Add context options to pipeline orchestrator initialization

* fix: Deduplicate React and handle CJS interop for use-sync-external-store

Resolve "Cannot read properties of null (reading 'useState')" errors by
deduplicating React/react-dom and ensuring use-sync-external-store is
bundled together with React to prevent CJS packages from resolving to
different React instances.

* Changes from feature/worktree-view-customization

* refactor: Remove unused worktree swap and highlight props

* refactor: Consolidate feature completion logic and improve thinking level defaults

* feat: Increase max turn limit to 10000

- Update DEFAULT_MAX_TURNS from 1000 to 10000 in settings-helpers.ts and agent-executor.ts
- Update MAX_ALLOWED_TURNS from 2000 to 10000 in settings-helpers.ts
- Update UI clamping logic from 2000 to 10000 in app-store.ts
- Update fallback values from 1000 to 10000 in use-settings-sync.ts
- Update default value from 1000 to 10000 in DEFAULT_GLOBAL_SETTINGS
- Update documentation to reflect new range: 1-10000

Allows agents to perform up to 10000 turns for complex feature execution.

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

* feat: Add model resolution, improve session handling, and enhance UI stability

* refactor: Remove unused sync and tracking branch props from worktree components

* feat: Add PR number update functionality to worktrees. Address pr feedback

* feat: Optimize Gemini CLI startup and add tool result tracking

* refactor: Improve error handling and simplify worktree task cleanup

---------

Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-02-23 20:31:25 -08:00
gsxdsm
e7504b247f Add quick-add feature with improved workflows (#802)
* Changes from feature/quick-add

* feat: Clarify system prompt and improve error handling across services. Address PR Feedback

* feat: Improve PR description parsing and refactor event handling

* feat: Add context options to pipeline orchestrator initialization

* fix: Deduplicate React and handle CJS interop for use-sync-external-store

Resolve "Cannot read properties of null (reading 'useState')" errors by
deduplicating React/react-dom and ensuring use-sync-external-store is
bundled together with React to prevent CJS packages from resolving to
different React instances.
2026-02-22 20:48:09 -08:00
gsxdsm
9305ecc242 Fix: Restore views properly, model selection for commit and pr and speed up some cli models with session resume (#801)
* Changes from fix/restoring-view

* feat: Add resume query safety checks and optimize store selectors

* feat: Improve session management and model normalization

* refactor: Extract prompt building logic and handle file path parsing for renames
2026-02-22 10:45:45 -08:00
gsxdsm
2f071a1ba3 Fix deleting worktree crash and improve UX (#798)
* Changes from fix/deleting-worktree

* fix: Improve worktree deletion safety and branch cleanup logic

* fix: Improve error handling and async operations across auto-mode and worktree services

* Update apps/server/src/routes/auto-mode/routes/analyze-project.ts

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-02-22 00:58:00 -08:00
gsxdsm
1d732916f1 Fix event hooks not persisting across server syncs (#799)
* Changes from fix/event-hook-persistence

* feat: Add explicit permission escape hatch for clearing eventHooks and improve error handling in UI
2026-02-22 00:36:08 -08:00
gsxdsm
629fd24d9f Improve pull request prompt and generation handling (#800)
* Changes from fix/improve-pull-request-prompt

* Update apps/ui/src/components/views/board-view/dialogs/create-pr-dialog.tsx

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-02-22 00:27:39 -08:00
gsxdsm
72cb942788 Fix Codex CLI timeout handling and improve CI workflows (#797)
* Changes from fix/codex-cli-timeout

* test: Clarify timeout values and multipliers in codex-provider tests

* refactor: Rename useWorktreesEnabled to worktreesEnabled for clarity
2026-02-21 23:58:09 -08:00
gsxdsm
91bff21d58 Feature: Git sync, set-tracking, and push divergence handling (#796) 2026-02-21 18:54:16 -08:00
gsxdsm
dfa719079f Changes from fix/manual-crash (#795) 2026-02-21 17:32:34 -08:00
gsxdsm
28becb177b Fix Docker Compose CORS issues with nginx API proxying (#793)
* Changes from fix/docker-compose-cors-error

* Update apps/server/src/index.ts

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Fix: Delete Worktree Crash + PR Comments + Dev Server UX Improvements (#792)

* Changes from fix/delete-worktree-hotifx

* fix: Improve bot detection and prevent UI overflow issues

- Include GitHub app-initiated comments in bot detection
- Wrap handleQuickCreateSession with useCallback to fix dependency issues
- Truncate long branch names in agent header to prevent layout overflow

* feat: Support GitHub App comments in PR review and fix session filtering

* feat: Return invalidation result from delete session handler

* fix: Improve CORS origin validation to handle wildcard correctly

* fix: Correct IPv6 localhost parsing and improve responsive UI layouts

* Changes from fix/pwa-cache-fix (#794)

* fix: Add type checking to prevent crashes from malformed cache entries

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-02-21 13:56:48 -08:00
gsxdsm
f785f1204b Changes from fix/pwa-cache-fix (#794) 2026-02-21 12:45:18 -08:00
gsxdsm
f3edfbf24e Fix: Delete Worktree Crash + PR Comments + Dev Server UX Improvements (#792)
* Changes from fix/delete-worktree-hotifx

* fix: Improve bot detection and prevent UI overflow issues

- Include GitHub app-initiated comments in bot detection
- Wrap handleQuickCreateSession with useCallback to fix dependency issues
- Truncate long branch names in agent header to prevent layout overflow

* feat: Support GitHub App comments in PR review and fix session filtering

* feat: Return invalidation result from delete session handler
2026-02-21 11:07:16 -08:00
gsxdsm
3ddf26f666 Fix: Dev server detection bug fixes. Settings sync bug fixes. Cli provider fixes. Terminal background/foreground colors (#791)
* Changes from fix/dev-server-state-bug

* feat: Add configurable max turns setting with user overrides. Address pr comments

* fix: Update default behaviors and improve state management across server and UI

* feat: Extract branch sync logic to separate service. Fix settings sync bug. Address pr comments

* refactor: Extract magic numbers to named constants and improve branch tracking logic

- Add DEFAULT_MAX_TURNS (1000) and MAX_ALLOWED_TURNS (2000) constants to settings-helpers
- Replace hardcoded 1000 values with DEFAULT_MAX_TURNS constant throughout codebase
- Improve max turns validation with explicit Number.isFinite check
- Update getTrackingBranch to split on first slash instead of last for better remote parsing
- Change isBranchCheckedOut return type from boolean to string|null to return worktree path
- Add comments explaining skipFetch parameter in worktree creation
- Fix cleanup order in AgentExecutor finally block to run before logging
```

* feat: Add comment refresh and improve model sync in PR dialog
2026-02-21 08:57:04 -08:00
gsxdsm
c81ea768a7 Feature: Add PR review comments and resolution, improve AI prompt handling (#790)
* feat: Add PR review comments and resolution endpoints, improve prompt handling

* Feature: File Editor (#789)

* feat: Add file management feature

* feat: Add auto-save functionality to file editor

* fix: Replace HardDriveDownload icon with Save icon for consistency

* fix: Prevent recursive copy/move and improve shell injection prevention

* refactor: Extract editor settings form into separate component

* ```
fix: Improve error handling and stabilize async operations

- Add error event handlers to GraphQL process spawns to prevent unhandled rejections
- Replace execAsync with execFile for safer command execution and better control
- Fix timeout cleanup in withTimeout generator to prevent memory leaks
- Improve outdated comment detection logic by removing redundant condition
- Use resolveModelString for consistent model string handling
- Replace || with ?? for proper falsy value handling in dialog initialization
- Add comments clarifying branch name resolution logic for local branches with slashes
- Add catch handler for project selection to handle async errors gracefully
```

* refactor: Extract PR review comments logic to dedicated service

* fix: Improve robustness and UX for PR review and file operations

* fix: Consolidate exec utilities and improve type safety

* refactor: Replace ScrollArea with div and improve file tree layout
2026-02-20 21:34:40 -08:00
gsxdsm
0e020f7e4a Feature: File Editor (#789)
* feat: Add file management feature

* feat: Add auto-save functionality to file editor

* fix: Replace HardDriveDownload icon with Save icon for consistency

* fix: Prevent recursive copy/move and improve shell injection prevention

* refactor: Extract editor settings form into separate component
2026-02-20 16:06:44 -08:00
gsxdsm
0a5540c9a2 Fix concurrency limits and remote branch fetching issues (#788)
* Changes from fix/bug-fixes

* feat: Refactor worktree iteration and improve error logging across services

* feat: Extract URL/port patterns to module level and fix abort condition

* fix: Improve IPv6 loopback handling, select component layout, and terminal UI

* feat: Add thinking level defaults and adjust list row padding

* Update apps/ui/src/store/app-store.ts

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* feat: Add worktree-aware terminal creation and split options, fix npm security issues from audit

* feat: Add tracked remote detection to pull dialog flow

* feat: Add merge state tracking to git operations

* feat: Improve merge detection and add post-merge action preferences

* Update apps/ui/src/components/views/board-view/dialogs/git-pull-dialog.tsx

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Update apps/ui/src/components/views/board-view/dialogs/git-pull-dialog.tsx

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* fix: Pass merge detection info to stash reapplication and handle merge state consistently

* fix: Call onPulled callback in merge handlers and add validation checks

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-02-20 13:48:22 -08:00
gsxdsm
7df2182818 Improve pull request flow, add branch selection for worktree creation, fix auto-mode concurrency count (#787)
* Changes from fix/fetch-before-pull-fetch

* feat: Improve pull request flow, add branch selection for worktree creation, fix for automode concurrency count

* feat: Add validation for remote names and improve error handling

* Address PR comments and mobile layout fixes

* ```
refactor: Extract PR target resolution logic into dedicated service
```

* feat: Add app shell UI and improve service imports. Address PR comments

* fix: Improve security validation and cache handling in git operations

* feat: Add GET /list endpoint and improve parameter handling

* chore: Improve validation, accessibility, and error handling across apps

* chore: Format vite server port configuration

* fix: Add error handling for gh pr list command and improve offline fallbacks

* fix: Preserve existing PR creation time and improve remote handling
2026-02-19 21:55:12 -08:00
DhanushSantosh
ee52333636 chore: refresh lockfile after dependency sync 2026-02-20 00:08:13 +05:30
gsxdsm
47bd7a76cf Merge pull request #782 from gsxdsm/feat/mobile-improvements
Feature: Comprehensive mobile improvements and bug fixes
2026-02-18 23:51:32 -08:00
gsxdsm
ae10dea2bf feat: Add includeUntracked option and improve error handling for stash operations 2026-02-18 23:32:24 -08:00
gsxdsm
be4153c374 fix: Improve error handling and state management in auto-mode and utilities 2026-02-18 23:12:11 -08:00
gsxdsm
a144a63c51 fix: Resolve git operation error handling and conflict detection issues 2026-02-18 23:03:39 -08:00
gsxdsm
205f662022 fix: Improve error handling and validation across multiple services 2026-02-18 22:11:31 -08:00
gsxdsm
53d07fefb8 feat: Fix new branch issues and address code review comments 2026-02-18 21:36:00 -08:00
gsxdsm
2d907938cc feat: Add TypeScript type annotation and fix session_id default value 2026-02-18 20:55:58 -08:00
gsxdsm
15ca1eb6d3 feat: Add process abort control and improve auth detection 2026-02-18 20:48:37 -08:00
gsxdsm
4ee160fae4 fix: Address review comments 2026-02-18 19:52:25 -08:00
gsxdsm
4ba0026aa1 feat: Add conflict resolution event types 2026-02-18 19:31:09 -08:00
gsxdsm
983eb21faa feat: Address review comments, add stage/unstage functionality, conflict resolution improvements, support for Sonnet 4.6 2026-02-18 18:58:33 -08:00
gsxdsm
df9a6314da refactor: Enhance session management and error handling in AgentService and related components
- Improved session handling by implementing ensureSession to load sessions from disk if not in memory, reducing "session not found" errors.
- Enhanced error messages for non-existent sessions, providing clearer diagnostics.
- Updated CodexProvider and OpencodeProvider to improve error handling and messaging.
- Refactored various routes to use async/await for better readability and error handling.
- Added event emission for merge and stash operations in the MergeService and StashService.
- Cleaned up error messages in AgentExecutor to remove redundant prefixes and ANSI codes for better clarity.
2026-02-18 17:30:12 -08:00
gsxdsm
6903d3c508 fix: Standardize event name and import path 2026-02-18 11:21:43 -08:00
gsxdsm
5c441f2313 feat: Add GPT-5 model variants and improve Codex execution logic. Addressed code review comments 2026-02-18 11:15:38 -08:00
Dhanush Santosh
00f9891237 Merge pull request #783 from DhanushSantosh/patchcraft
fix: release workflow assets + missing EventType entry
2026-02-18 16:13:45 +05:30
gsxdsm
d30296d559 feat: Add git log parsing and rebase endpoint with input validation 2026-02-18 00:37:41 -08:00
gsxdsm
e6e04d57bc Update apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-02-17 23:37:49 -08:00
gsxdsm
829c16181b Update apps/ui/src/components/views/board-view/dialogs/discard-worktree-changes-dialog.tsx
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-02-17 23:37:10 -08:00
gsxdsm
13261b7e8c Update apps/ui/src/components/dialogs/project-file-selector-dialog.tsx
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-02-17 23:36:43 -08:00
gsxdsm
854ba6ec74 fix: Add symlink validation to prevent path traversal attacks 2026-02-17 23:22:08 -08:00
gsxdsm
bddf1a4bf8 fix: Handle staged-new files correctly in discard changes 2026-02-17 23:19:38 -08:00
gsxdsm
887e2ea76b fix: Correct parsing of git output blocks and improve stash UI accessibility 2026-02-17 23:15:21 -08:00
gsxdsm
dd4c738e91 fix: Address code review comments 2026-02-17 23:15:21 -08:00
gsxdsm
43c19c70ca Update apps/server/src/routes/worktree/routes/discard-changes.ts
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-02-17 22:38:55 -08:00
DhanushSantosh
627580a8f0 chore: untrack check-sync.sh and DEVELOPMENT_WORKFLOW.md
These are fork-local workflow tools already listed in .gitignore.
Removing from git tracking so they persist locally across branch
switches and are never accidentally staged or pushed.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-18 11:58:57 +05:30
DhanushSantosh
a2d5c1d546 Merge remote-tracking branch 'upstream/v0.15.0rc' into patchcraft 2026-02-18 11:54:08 +05:30
DhanushSantosh
6b9946df95 chore: restore check-sync.sh and DEVELOPMENT_WORKFLOW.md
These files were accidentally dropped from patchcraft. Restoring from
upstream/main to preserve the sync workflow tooling and documentation.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-18 11:48:33 +05:30
gsxdsm
cb99c4b4e8 feat: Replace Select with Popover+Command for branch selection UI 2026-02-17 22:08:22 -08:00
gsxdsm
9af63bc1ef refactor: Improve all git operations, add stash support, add improved pull request flow, add worktree file copy options, address code review comments, add cherry pick options 2026-02-17 22:02:58 -08:00
DhanushSantosh
17a99a0e20 fix: restrict Linux native bindings install to Linux runners only
The setup-project action was force-installing Linux-specific npm binaries
(@rollup/rollup-linux-x64-gnu, @tailwindcss/oxide-linux-x64-gnu) on ALL
platforms including macOS and Windows. This overwrote the correct
platform-native binaries, causing Vite builds to fail on those runners,
which prevented any release assets from being uploaded.

Also removes the redundant `draft == false` condition from the upload job
(already guaranteed by `types: [published]` trigger) and adds an explicit
checkout step to the upload job for correctness.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-18 11:16:06 +05:30
gsxdsm
f4e87d4c25 Update apps/ui/src/styles/global.css
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-02-17 19:39:32 -08:00
gsxdsm
c7f515adde feat: Add auto-fix for SSH URLs in lockfile before linting 2026-02-17 18:52:15 -08:00
gsxdsm
1df778a9db chore: Add PageTransitionEvent and APP_BUILD_HASH to eslint globals 2026-02-17 17:37:35 -08:00
gsxdsm
cb44f8a717 Comprehensive set of mobile and all improvements phase 1 2026-02-17 17:33:11 -08:00
gsxdsm
7fcf3c1e1f feat: Mobile improvements and Add selective file staging and improve branch switching 2026-02-17 15:20:28 -08:00
gsxdsm
de021f96bf fix: Remove unused vars and improve type safety. Improve task recovery 2026-02-17 13:18:40 -08:00
gsxdsm
8bb10632b1 Merge remote-tracking branch 'upstream/v0.15.0rc' into feat/add-zai-usage-tracking
# Conflicts:
#	apps/ui/src/components/usage-popover.tsx
#	apps/ui/src/components/views/board-view/mobile-usage-bar.tsx
2026-02-17 11:19:06 -08:00
gsxdsm
06ef4f883f Merge pull request #781 from gsxdsm/fix/improve-restart-recovery
feat: Add feature state reconciliation on server startup
2026-02-17 11:15:03 -08:00
gsxdsm
7e84591ef1 Merge pull request #774 from gsxdsm/feat/duplicate-festure
Feat: Add ability to duplicate a feature and duplicate as a child
2026-02-17 10:43:04 -08:00
gsxdsm
efcdd849b9 fix: Add 'ready' status to FeatureStatusWithPipeline type union 2026-02-17 10:37:45 -08:00
gsxdsm
dee770c2ab refactor: Consolidate global settings fetching to avoid duplicate calls 2026-02-17 10:32:20 -08:00
gsxdsm
f7b3f75163 feat: Add path validation and security improvements to worktree routes 2026-02-17 10:17:23 -08:00
gsxdsm
b5ad77b0f9 feat: Add feature state reconciliation on server startup 2026-02-17 09:56:54 -08:00
DhanushSantosh
98b925b821 Merge remote-tracking branch 'upstream/v0.15.0rc' into patchcraft 2026-02-17 19:57:42 +05:30
gsxdsm
a09a2c76ae fix: Address code review feedback and fix lint errors 2026-02-17 00:13:38 -08:00
gsxdsm
b9653d6338 fix: Strip runtime and state fields when duplicating features 2026-02-16 23:41:08 -08:00
gsxdsm
44ef2084cf Merge remote-tracking branch 'upstream/v0.15.0rc' into feat/duplicate-festure
# Conflicts:
#	apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx
#	apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx
#	apps/ui/src/components/views/board-view/hooks/use-board-persistence.ts
2026-02-16 23:28:32 -08:00
gsxdsm
57446b4fba Merge pull request #778 from gsxdsm/fix/features-completed-too-soon
feat: Add task retry logic and improve max turns limit
2026-02-16 23:11:32 -08:00
gsxdsm
fa799d3cb5 feat: Implement optimistic updates for feature persistence
Add optimistic UI updates with rollback capability for feature creation and deletion operations. Await persistFeatureDelete promise and add Playwright testing dependency.
2026-02-16 23:08:09 -08:00
gsxdsm
78ec389477 Merge remote-tracking branch 'upstream/main' into feat/duplicate-festure 2026-02-16 22:56:35 -08:00
gsxdsm
f06088a062 feat: Update maxTurns default from 20 to 100 and format code 2026-02-16 22:47:30 -08:00
gsxdsm
8af1b8bd08 chore: Increase default max turns for agent execution from 20/50 to 100 2026-02-16 22:39:13 -08:00
gsxdsm
d5340fd1a4 Update apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-02-16 22:19:26 -08:00
gsxdsm
aa940d44ff feat: Add task retry logic and improve max turns limit 2026-02-16 22:10:50 -08:00
gsxdsm
381698b048 Merge pull request #776 from gsxdsm/fix/claude-weekly-usage
feat: Add error handling to auto-mode facade and implement followUp f…
2026-02-16 21:33:37 -08:00
gsxdsm
30fce3f746 test: Update task finalization behavior to keep pending tasks in review states 2026-02-16 21:25:57 -08:00
gsxdsm
4a8c6b0eba Update feature-state-manager.test.ts
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-02-16 20:47:38 -08:00
gsxdsm
416ef3a394 feat: Add error handling to auto-mode facade and implement followUp feature. Fix Claude weekly usage indicator. Fix mobile card drag 2026-02-16 18:58:42 -08:00
gsxdsm
2805c0ea53 Merge pull request #775 from gsxdsm/refactor/auto-mode-service-gsxdsm
fix: Exclude waiting_approval cards from active running state display
2026-02-16 14:21:52 -08:00
gsxdsm
727a7a5b9d feat: Exclude waiting_approval cards from active running state display 2026-02-16 14:14:17 -08:00
gsxdsm
46dd219d15 Merge pull request #771 from gsxdsm/refactor/auto-mode-service-gsxdsm
refactor: AutoModeService decomposition (Phases 1-6) #733 + fixes
2026-02-16 13:43:28 -08:00
gsxdsm
67dd628115 test: Add mock for getCurrentBranch in pipeline orchestrator tests 2026-02-16 13:35:49 -08:00
gsxdsm
ab5d6a0e54 feat: Improve callback safety and remove unnecessary formatting in auto-mode facade 2026-02-16 13:14:55 -08:00
gsxdsm
0b03e70f1d fix: Resolve null coalescing, feature verification, and test abort handling issues 2026-02-16 12:27:56 -08:00
gsxdsm
434792a2ef fix: Normalize 'main' branch to __main__ in auto-loop key generation 2026-02-16 12:07:05 -08:00
gsxdsm
462dbf1522 fix: Address code review comments 2026-02-16 11:53:09 -08:00
gsxdsm
eed5e20438 fix(agent-service): fallback to effectiveModel when requestedModel is undefined 2026-02-16 10:47:52 -08:00
gsxdsm
bea26a6b61 style: Fix inconsistent indentation in components and imports 2026-02-15 22:50:01 -08:00
eclipxe
e9802ac00c Feat: Add ability to duplicate a feature and duplicate as a child 2026-02-15 21:28:07 -08:00
gsxdsm
41014f6ab6 fix: resolve TypeScript errors after upstream merge
Add missing 'adaptive' thinking level to kanban card labels and export
TerminalPromptTheme type from @automaker/types package.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 21:04:18 -08:00
eclipxe
ac2e8cfa88 Feat: Add z.ai usage tracking 2026-02-15 20:57:09 -08:00
eclipxe
7d5bc722fa Feat: Show Gemini Usage in usage dropdown and mobile sidebar 2026-02-15 20:56:53 -08:00
eclipxe
7765a12868 Feat: Add z.ai usage tracking 2026-02-15 20:55:37 -08:00
Web Dev Cody
dfe6920df9 Merge pull request #772 from AutoMaker-Org/gsxdsm-patch-1
Update README to remove maintenance notice
2026-02-15 20:47:34 -05:00
gsxdsm
525b2f82b6 Update README to remove maintenance notice
Removed maintenance warning from README.
2026-02-15 17:18:33 -08:00
gsxdsm
f459b73cb5 fix: update kanban card status handling
- Enhanced the Kanban card component to support additional feature statuses ('interrupted' and 'ready') in the backlog display logic.
- Updated relevant components to reflect these changes, ensuring consistent behavior across the UI.
2026-02-15 10:38:23 -08:00
gsxdsm
a935229031 fix: enhance error handling in feature creation process
- Added error handling for feature creation in BoardView component to log errors and display user-friendly messages.
- Updated persistFeatureCreate function to throw errors on failure, allowing for better state management.
- Introduced removal of features from state if server creation fails, improving user experience during conflicts.

Also added @playwright/test to devDependencies in package-lock.json for improved testing capabilities.
2026-02-15 10:21:39 -08:00
gsxdsm
a3a5c9e2cb Merge remote-tracking branch 'upstream/v0.15.0rc' into refactor/auto-mode-service-gsxdsm 2026-02-15 10:20:53 -08:00
Shirone
1662c6bf0b Merge pull request #745 from AutoMaker-Org/fix/docker-playwright-missing-browsers
fix(docker): Pre-install Playwright Chromium browsers for automated t…
2026-02-15 17:17:41 +00:00
Shirone
a08ba1b517 fix: address PR #745 review comments
- .gitignore: add missing trailing newline
- Dockerfile: remove misleading || echo fallback in Playwright install
- index.ts: truncate long paths from beginning instead of end in warning box
- verify-claude-auth.ts: use effectiveAuthMethod to prevent undefined authType
- agent-context-parser.ts: handle claude-opus alias as Opus 4.6
- thinking-level-selector.tsx: improve model prop documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 18:13:06 +01:00
Shirone
8226699734 fix(docker): add @playwright/test to server devDependencies
The Dockerfile's playwright install step requires the binary in
node_modules/.bin/, but playwright was only a UI dependency. This adds
@playwright/test to server devDependencies so the Docker build can
successfully run `./node_modules/.bin/playwright install chromium`.

Fixes the "playwright: not found" error during Docker image build.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 18:01:45 +01:00
Shirone
d4439fafa0 Merge branch 'v0.15.0rc' into fix/docker-playwright-missing-browsers 2026-02-15 17:55:31 +01:00
Shirone
6f1325f3ee Merge pull request #747 from AutoMaker-Org/feature/bug-startup-warning-ignores-claude-oauth-credenti-fuzx
fix(auth): Improve OAuth credential detection and startup warning
2026-02-15 16:52:55 +00:00
Shirone
d4f68b659b fix: address PR #747 review comments
- Fix warning box path lines being 2 chars too wide (BOX_CONTENT_WIDTH - 4)
- Wrap getClaudeAuthIndicators in try/catch to prevent 500 on auth success
- Convert dynamic import to static import for @automaker/platform
- Simplify verbose debug logging to log objects directly
- Remove unnecessary truthy checks on always-populated path strings

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 17:46:25 +01:00
Shirone
ad6ce738b4 Merge remote-tracking branch 'origin/v0.15.0rc' into feature/bug-startup-warning-ignores-claude-oauth-credenti-fuzx 2026-02-15 17:37:17 +01:00
Shirone
67ebf8c14b Merge pull request #757 from AutoMaker-Org/feat/new-claude-and-codex-models
feat: add Claude Opus 4.6 and GPT-5.3-Codex model support
2026-02-15 16:16:08 +00:00
Shirone
8ed13564f6 fix: address PR #757 review comments
- Extract getNvmWindowsCliPaths() helper to DRY up NVM_SYMLINK logic
- Update DEFAULT_MODELS.codex to gpt53Codex
- Simplify redundant ternary in thinking-level-selector
- Replace local supportsReasoningEffort with shared import from @automaker/types
- Use model.id fallback in phase-model-selector thinking level resolution

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 16:59:54 +01:00
Shirone
09507bff67 Merge branch 'v0.15.0rc' into feat/new-claude-and-codex-models 2026-02-15 16:49:41 +01:00
Shirone
c70344156d chore: update .gitignore to include new configuration files
- Added .mcp.json and .planning to .gitignore to prevent tracking of configuration files.
2026-02-15 16:46:29 +01:00
gsxdsm
8542a32f4f refactor(auto-mode): enhance feature retrieval logic in facade and global service
- Replaced synchronous feature retrieval with asynchronous logic in both AutoModeServiceFacade and GlobalAutoModeService.
- Updated filtering logic to resolve the primary branch name for main worktrees, improving accuracy in feature status checks.
- This change enhances the responsiveness and correctness of feature handling in auto mode operations.
2026-02-14 21:28:15 -08:00
gsxdsm
0745832d1e refactor(auto-mode): convert getStatusForProject to async and enhance feature retrieval
- Updated getStatusForProject method in AutoModeServiceCompat and its facade to be asynchronous, allowing for better handling of feature status retrieval.
- Modified related status handlers in the server routes to await the updated method.
- Introduced a new method, getRunningFeaturesForWorktree, in ConcurrencyManager to improve feature ID retrieval based on branch normalization.
- Adjusted BoardView component to ensure consistent handling of running auto tasks across worktrees.

These changes improve the responsiveness and accuracy of the auto mode feature in the application.
2026-02-14 21:07:24 -08:00
gsxdsm
0f0f5159d2 feat(auto-mode): implement facade caching and enhance error handling
- Added caching for facades in AutoModeServiceCompat to persist auto loop state across API calls.
- Improved error handling in BoardView for starting and stopping auto mode, with user-friendly toast notifications.
- Updated WorktreePanel to manage auto mode state and concurrency limits more effectively.
- Enhanced useAutoMode hook to prevent state overwrites during transitions and synchronize UI with backend status.

This update optimizes performance and user experience in the auto mode feature.
2026-02-14 20:37:03 -08:00
gsxdsm
bcc854234c Fix custom providers not passing model name properly 2026-02-14 19:24:10 -08:00
Kacper
5ffbfb3217 fix(server): Address PR #733 review feedback and fix cross-platform tests
- Extract merge logic from pipeline-orchestrator to merge-service.ts to avoid HTTP self-call
- Make agent-executor error handling provider-agnostic using shared isAuthenticationError utility
- Fix cross-platform path handling in tests using path.normalize/path.resolve helpers
- Add catch handlers in plan-approval-service tests to prevent unhandled promise rejection warnings

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-14 18:54:02 -08:00
Shirone
7c89923a6e fix: address PR review issues for auto-mode refactor
- agent-executor: move executeQuery into try block for proper heartbeat cleanup,
  re-parse tasks when edited plan is approved
- auto-loop-coordinator: handle feature execution failures with proper logging
  and failure tracking, support backward-compatible method signatures
- facade: delegate getActiveAutoLoopProjects/Worktrees to coordinator,
  always create own AutoLoopCoordinator (not shared), pass projectPath
  to approval methods and branchName to failure tracking
- global-service: document shared autoLoopCoordinator is for monitoring only
- execution-types: fix ExecuteFeatureFn type to match implementation
- feature-state-manager: use readJsonWithRecovery for loadFeature
- pipeline-orchestrator: add defensive null check and try/catch for
  merge response parsing
- plan-approval-service: use project-scoped keys to prevent cross-project
  collisions, maintain backward compatibility for featureId-only lookups

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-14 18:54:02 -08:00
Shirone
63b1a353d9 fix(facade): pass previousContent to AgentExecutor for pipeline steps
The PipelineOrchestrator passes previousContent to preserve the agent
output history when running pipeline steps. This was being lost because
the facade's runAgentFn callback wasn't forwarding it to AgentExecutor.

Without this fix, pipeline steps would overwrite the agent-output.md
file instead of appending to it with a "Follow-up Session" separator.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-14 18:54:02 -08:00
Shirone
49bdaaae71 fix(agent-executor): restore wrench emoji in tool output format
The wrench emoji (🔧) was accidentally removed in commit 6ec9a257
during the service condensing refactor. This broke:

1. Log parser - uses startsWith('🔧') to detect tool calls, causing
   them to be categorized as "info" instead of "tool_call"
2. Agent context parser - uses '🔧 Tool: TodoWrite' marker to find
   tasks, causing task list to not appear on kanban cards

This fix restores the emoji to fix both issues.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-14 18:54:02 -08:00
Shirone
28224e1051 fix(facade): wire runAgentFn to AgentExecutor.execute
The facade had stubs for runAgentFn that threw errors, causing feature
execution to fail with "runAgentFn not implemented in facade".

This fix wires both ExecutionService and PipelineOrchestrator runAgentFn
callbacks to properly call AgentExecutor.execute() with:
- Provider from ProviderFactory.getProviderForModel()
- Bare model from stripProviderPrefix()
- Proper AgentExecutorCallbacks for waitForApproval, saveFeatureSummary, etc.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-14 18:54:02 -08:00
Shirone
df10bcd6df fix: lock file 2026-02-14 18:54:02 -08:00
Shirone
0ed4494992 chore(deps): update lint-staged version and change node-gyp repository URL
- Updated lint-staged dependency to use caret versioning (^16.2.7) in package.json and package-lock.json.
- Changed the resolved URL for node-gyp in package-lock.json from HTTPS to SSH.
2026-02-14 18:54:02 -08:00
Shirone
43309e383f refactor(06-04): extract types and condense agent-executor/pipeline-orchestrator
- Create agent-executor-types.ts with execution option/result/callback types
- Create pipeline-types.ts with context/status/result types
- Condense agent-executor.ts stream processing and add buildExecOpts helper
- Condense pipeline-orchestrator.ts methods and simplify event emissions

Further line reduction limited by Prettier reformatting condensed code.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-14 18:54:02 -08:00
Shirone
efd4284c10 refactor(06-04): trim 5 oversized services to under 500 lines
- agent-executor.ts: 1317 -> 283 lines (merged duplicate task loops)
- execution-service.ts: 675 -> 314 lines (extracted callback types)
- pipeline-orchestrator.ts: 662 -> 471 lines (condensed methods)
- auto-loop-coordinator.ts: 590 -> 277 lines (condensed type definitions)
- recovery-service.ts: 558 -> 163 lines (simplified state methods)

Created execution-types.ts for callback type definitions.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-14 18:54:02 -08:00
Shirone
473f935c90 refactor(06-04): delete auto-mode-service.ts monolith
- Delete the 2705-line auto-mode-service.ts monolith
- Create AutoModeServiceCompat as compatibility layer for routes
- Create GlobalAutoModeService for cross-project operations
- Update all routes to use AutoModeServiceCompat type
- Add SharedServices interface for state sharing across facades
- Add getActiveProjects/getActiveWorktrees to AutoLoopCoordinator
- Delete obsolete monolith test files

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-14 18:54:02 -08:00
Shirone
7fd3d61a59 refactor(06-03): migrate Batch 5 secondary routes and wire router index
- features/routes/list.ts: Add facadeFactory parameter, use facade.detectOrphanedFeatures
- projects/routes/overview.ts: Add facadeFactory parameter, use facade.getRunningAgents/getStatusForProject
- features/index.ts: Pass facadeFactory to list handler
- projects/index.ts: Pass facadeFactory to overview handler
- auto-mode/index.ts: Accept facadeFactory parameter and wire to all route handlers
- All routes maintain backward compatibility with autoModeService fallback
2026-02-14 18:53:24 -08:00
Shirone
7bc1f68699 refactor(06-03): migrate Batch 4 complex routes to facade pattern
- run-feature.ts: Add facadeFactory parameter, use facade.checkWorktreeCapacity/executeFeature
- follow-up-feature.ts: Add facadeFactory parameter, use facade.followUpFeature
- approve-plan.ts: Add facadeFactory parameter, use facade.resolvePlanApproval
- analyze-project.ts: Add facadeFactory parameter, use facade.analyzeProject
- All routes maintain backward compatibility with autoModeService fallback
2026-02-14 18:53:24 -08:00
Shirone
ade22ef258 refactor(06-03): migrate Batch 3 feature lifecycle routes to facade pattern
- start.ts: Add facadeFactory parameter, use facade.isAutoLoopRunning/startAutoLoop
- resume-feature.ts: Add facadeFactory parameter, use facade.resumeFeature
- resume-interrupted.ts: Add facadeFactory parameter, use facade.resumeInterruptedFeatures
- All routes maintain backward compatibility with autoModeService fallback
2026-02-14 18:53:24 -08:00
Shirone
31f8afc115 refactor(06-02): migrate Batch 2 state change routes to facade pattern
- stop-feature.ts: Add facade parameter for feature stopping
- stop.ts: Add facadeFactory parameter for auto loop control
- verify-feature.ts: Add facadeFactory parameter for verification
- commit-feature.ts: Add facadeFactory parameter for committing

All routes maintain backward compatibility by accepting both
autoModeService (legacy) and facade/facadeFactory (new).
2026-02-14 18:53:24 -08:00
Shirone
071af1b5c3 refactor(06-02): migrate Batch 1 query-only routes to facade pattern
- status.ts: Add facadeFactory parameter for per-project status
- context-exists.ts: Add facadeFactory parameter for context checks
- running-agents/index.ts: Add facade parameter for getRunningAgents

All routes maintain backward compatibility by accepting both
autoModeService (legacy) and facade/facadeFactory (new).
2026-02-14 18:53:24 -08:00
Shirone
1b32a6bc3a chore(06-01): create index.ts with exports
- Export AutoModeServiceFacade class
- Export createAutoModeFacade convenience factory function
- Re-export all types from types.ts for route consumption
- Re-export types from extracted services (AutoModeConfig, RunningFeature, etc.)

All 1809 server tests pass.
2026-02-14 18:53:23 -08:00
Shirone
a0484624b7 feat(06-01): create AutoModeServiceFacade with all 23 methods
- Create facade.ts with per-project factory pattern
- Implement all 23 public methods from RESEARCH.md inventory:
  - Auto loop control: startAutoLoop, stopAutoLoop, isAutoLoopRunning, getAutoLoopConfig
  - Feature execution: executeFeature, stopFeature, resumeFeature, followUpFeature, verifyFeature, commitFeature
  - Status queries: getStatus, getStatusForProject, getActiveAutoLoopProjects, getActiveAutoLoopWorktrees, getRunningAgents, checkWorktreeCapacity, contextExists
  - Plan approval: resolvePlanApproval, waitForPlanApproval, hasPendingApproval, cancelPlanApproval
  - Analysis/recovery: analyzeProject, resumeInterruptedFeatures, detectOrphanedFeatures
  - Lifecycle: markAllRunningFeaturesInterrupted
- Use thin delegation pattern to underlying services
- Note: followUpFeature and analyzeProject require AutoModeService until full migration
2026-02-14 18:53:23 -08:00
Shirone
0383f85507 chore(06-01): create facade types and directory structure
- Create apps/server/src/services/auto-mode/ directory
- Add types.ts with FacadeOptions interface
- Re-export types from extracted services (AutoModeConfig, RunningFeature, etc.)
- Add facade-specific types (AutoModeStatus, WorktreeCapacityInfo, etc.)
2026-02-14 18:53:23 -08:00
Shirone
1a7dd5d1eb refactor(05-03): wire ExecutionService delegation in AutoModeService
- Replace executeFeature body with delegation to executionService.executeFeature()
- Replace stopFeature body with delegation to executionService.stopFeature()
- Remove ~312 duplicated lines from AutoModeService (3017 -> 2705)
- All 1809 server tests pass

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-14 18:53:23 -08:00
Shirone
afa60399dc refactor(05-03): remove duplicated methods from AutoModeService
- Replace startAutoLoopForProject body with delegation to autoLoopCoordinator
- Replace stopAutoLoopForProject body with delegation to autoLoopCoordinator
- Replace isAutoLoopRunningForProject body with delegation
- Replace getAutoLoopConfigForProject body with delegation
- Replace resumeFeature body with delegation to recoveryService
- Replace resumeInterruptedFeatures body with delegation
- Remove runAutoLoopForProject method (~95 lines) - now in AutoLoopCoordinator
- Remove failure tracking methods (~180 lines) - now in AutoLoopCoordinator
- Remove resolveMaxConcurrency (~40 lines) - now in AutoLoopCoordinator
- Update checkWorktreeCapacity to use coordinator
- Simplify legacy startAutoLoop to delegate
- Remove failure tracking from executeFeature (now handled by coordinator)

Line count reduced from 3604 to 3013 (~591 lines removed)
2026-02-14 18:53:23 -08:00
Shirone
1b39e25497 refactor(05-03): wire coordination services into AutoModeService
- Add imports for AutoLoopCoordinator, ExecutionService, RecoveryService
- Add private properties for the three coordination services
- Update constructor to accept optional parameters for dependency injection
- Create AutoLoopCoordinator with callbacks for loop lifecycle
- Create ExecutionService with callbacks for feature execution
- Create RecoveryService with callbacks for crash recovery
2026-02-14 18:51:26 -08:00
Shirone
828d0a0148 test(05-03): add RecoveryService unit tests
- Add 29 unit tests for crash recovery functionality
- Test execution state persistence (save/load/clear)
- Test context detection (agent-output.md exists check)
- Test feature resumption flow (pipeline vs non-pipeline)
- Test interrupted feature batch resumption
- Test idempotent behavior and error handling
2026-02-14 18:51:26 -08:00
Shirone
18624d12ce feat(05-03): create RecoveryService with crash recovery logic
- Add ExecutionState interface and DEFAULT_EXECUTION_STATE constant
- Export 7 callback types for AutoModeService integration
- Implement saveExecutionStateForProject/saveExecutionState for persistence
- Implement loadExecutionState/clearExecutionState for state management
- Add contextExists helper for agent-output.md detection
- Implement resumeFeature with pipeline/context-aware flow
- Implement resumeInterruptedFeatures for server restart recovery
- Add executeFeatureWithContext for conversation restoration
2026-02-14 18:51:26 -08:00
Shirone
71a0309a0b test(05-02): add ExecutionService unit tests
- Add 45 unit tests for execution lifecycle coordination
- Test constructor, executeFeature, stopFeature, buildFeaturePrompt
- Test approved plan handling, error handling, worktree resolution
- Test auto-mode integration, planning mode, summary extraction

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-14 18:51:26 -08:00
Shirone
e0f785aa99 feat(05-02): create ExecutionService with feature execution lifecycle
- Extract executeFeature, stopFeature, buildFeaturePrompt from AutoModeService
- Export callback types for test mocking and integration
- Implement persist-before-emit pattern for status updates
- Support approved plan continuation and context resumption
- Track failures and signal pause when threshold reached

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-14 18:51:26 -08:00
Shirone
2aa156ecbf test(05-01): add AutoLoopCoordinator unit tests
- 41 tests covering loop lifecycle and failure tracking
- Tests for getWorktreeAutoLoopKey key generation
- Tests for start/stop/isRunning/getConfig methods
- Tests for runAutoLoopForProject loop behavior
- Tests for failure tracking threshold and quota errors
- Tests for multiple concurrent projects/worktrees
- Tests for edge cases (null settings, reset errors)
2026-02-14 18:51:26 -08:00
Shirone
94a8e09516 feat(05-01): create AutoLoopCoordinator with loop lifecycle
- Extract loop lifecycle from AutoModeService
- Export AutoModeConfig, ProjectAutoLoopState, getWorktreeAutoLoopKey
- Export callback types for AutoModeService integration
- Methods: start/stop/isRunning/getConfig for project/worktree
- Failure tracking with threshold and quota error detection
- Sleep helper interruptible by abort signal
2026-02-14 18:51:26 -08:00
Shirone
78072550c7 refactor(04-02): remove duplicated pipeline methods from AutoModeService
- Delete executePipelineSteps method (~115 lines)
- Delete buildPipelineStepPrompt method (~38 lines)
- Delete resumePipelineFeature method (~88 lines)
- Delete resumeFromPipelineStep method (~195 lines)
- Delete detectPipelineStatus method (~104 lines)
- Remove unused PipelineStatusInfo interface (~18 lines)
- Update comments to reference PipelineOrchestrator

Total reduction: ~546 lines (4150 -> 3604 lines)
2026-02-14 18:51:26 -08:00
Shirone
0cd149f2e3 test(04-02): add PipelineOrchestrator delegation and edge case tests
- Add AutoModeService integration tests for delegation verification
- Test executePipeline delegation with context fields
- Test detectPipelineStatus delegation for pipeline/non-pipeline status
- Test resumePipeline delegation with autoLoadClaudeMd and useWorktrees
- Add edge case tests for abort signals, missing context, deleted steps
2026-02-14 18:51:26 -08:00
Shirone
2e577bb230 refactor(04-02): wire PipelineOrchestrator into AutoModeService
- Add PipelineOrchestrator constructor parameter and property
- Initialize PipelineOrchestrator with all required dependencies and callbacks
- Delegate executePipelineSteps to pipelineOrchestrator.executePipeline()
- Delegate detectPipelineStatus to pipelineOrchestrator.detectPipelineStatus()
- Delegate resumePipelineFeature to pipelineOrchestrator.resumePipeline()
2026-02-14 18:51:26 -08:00
Shirone
4f00b41cb0 test(04-01): add PipelineOrchestrator unit tests
- Tests for executePipeline: step sequence, events, status updates
- Tests for buildPipelineStepPrompt: context inclusion, previous work
- Tests for detectPipelineStatus: pipeline status detection and parsing
- Tests for resumePipeline/resumeFromStep: excluded steps, slot management
- Tests for executeTestStep: 5-attempt fix loop, failure events
- Tests for attemptMerge: merge endpoint, conflict detection
- Tests for buildTestFailureSummary: output parsing

37 tests covering all core functionality

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-14 18:51:26 -08:00
Shirone
ba45587a0a feat(04-01): create PipelineOrchestrator with step execution and auto-merge
- Extract pipeline orchestration logic from AutoModeService
- executePipeline: Sequential step execution with context continuity
- buildPipelineStepPrompt: Builds prompts with feature context and previous output
- detectPipelineStatus: Identifies pipeline status for resumption
- resumePipeline/resumeFromStep: Handle excluded steps and missing context
- executeTestStep: 5-attempt agent fix loop (REQ-F07)
- attemptMerge: Auto-merge with conflict detection (REQ-F05)
- buildTestFailureSummary: Concise test failure summary for agent

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-14 18:51:26 -08:00
Shirone
4912d37990 fix(03-03): fix type compatibility and cleanup unused imports
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-14 18:51:26 -08:00
Shirone
b24839bc49 test(03-03): add AgentExecutor execution tests
- Add 11 new test cases for execute() behavior
- Test callback invocation (progress events, tool events)
- Test error handling (API errors, auth failures)
- Test result structure and response accumulation
- Test abort signal propagation
- Test branchName propagation in event payloads

Test file: 388 -> 935 lines (+547 lines)
2026-02-14 18:51:26 -08:00
Shirone
e3a1c8c312 refactor(03-03): wire runAgent() to delegate to AgentExecutor.execute()
- Replace stream processing loop with AgentExecutor.execute() delegation
- Build AgentExecutionOptions object from runAgent() parameters
- Create callbacks for waitForApproval, saveFeatureSummary, etc.
- Remove ~930 lines of duplicated stream processing code
- Progress events now flow through AgentExecutor

File: auto-mode-service.ts reduced from 5086 to 4157 lines
2026-02-14 18:51:26 -08:00
Shirone
8f245e7757 refactor(03-02): wire AgentExecutor into AutoModeService
- Add AgentExecutor import to auto-mode-service.ts
- Add agentExecutor as constructor parameter (optional, with default)
- Initialize AgentExecutor with TypedEventBus, FeatureStateManager,
  PlanApprovalService, and SettingsService dependencies

This enables constructor injection for testing and prepares for
incremental delegation of runAgent() logic to AgentExecutor.
The AgentExecutor contains the full execution pipeline;
runAgent() delegation will be done incrementally to ensure
stability.
2026-02-14 18:51:26 -08:00
Shirone
cbb45b6612 test(03-02): add AgentExecutor tests
- Test constructor injection with all dependencies
- Test interface exports (AgentExecutionOptions, AgentExecutionResult)
- Test callback type signatures (WaitForApprovalFn, SaveFeatureSummaryFn, etc.)
- Test dependency injection patterns with custom implementations
- Verify execute method signature

Note: Full integration tests for streaming/marker detection require
complex mocking of @automaker/utils module which has hoisting issues.
Integration testing covered in E2E and auto-mode-service tests.
2026-02-14 18:51:26 -08:00
Shirone
25fa6fd616 feat(03-02): create AgentExecutor class with core streaming logic
- Create AgentExecutor class with constructor injection for TypedEventBus,
  FeatureStateManager, PlanApprovalService, and SettingsService
- Extract streaming pipeline from AutoModeService.runAgent()
- Implement execute() with stream processing, marker detection, file output
- Support recovery path with executePersistedTasks()
- Handle spec generation and approval workflow
- Multi-agent task execution with progress events
- Single-agent continuation fallback
- Debounced file writes (500ms)
- Heartbeat logging for silent model calls
- Abort signal handling throughout execution

Key interfaces:
- AgentExecutionOptions: All execution parameters
- AgentExecutionResult: responseText, specDetected, tasksCompleted, aborted
- Callbacks: waitForApproval, saveFeatureSummary, updateFeatureSummary, buildTaskPrompt
2026-02-14 18:51:26 -08:00
Shirone
ec5179eee9 refactor(03-01): wire SpecParser into AutoModeService
- Add import for all spec parsing functions from spec-parser.ts
- Remove 209 lines of function definitions (now imported)
- Functions extracted: parseTasksFromSpec, parseTaskLine, detectTaskStartMarker,
  detectTaskCompleteMarker, detectPhaseCompleteMarker, detectSpecFallback, extractSummary
- All server tests pass (1608 tests)
2026-02-14 18:51:26 -08:00
Shirone
2fac438cde feat(03-01): create SpecParser module with comprehensive tests
- Extract parseTasksFromSpec for parsing tasks from spec content
- Extract marker detection functions (task start/complete, phase complete)
- Extract detectSpecFallback for non-Claude model support
- Extract extractSummary with multi-format support and last-match behavior
- Add 65 unit tests covering all functions and edge cases
2026-02-14 18:51:25 -08:00
Shirone
5dca97dab4 refactor(02-01): wire PlanApprovalService into AutoModeService
- Add PlanApprovalService import and constructor parameter
- Delegate waitForPlanApproval, cancelPlanApproval, hasPendingApproval
- resolvePlanApproval checks needsRecovery flag and calls executeFeature
- Remove pendingApprovals Map (now in PlanApprovalService)
- Remove PendingApproval interface (moved to plan-approval-service.ts)
2026-02-14 18:51:25 -08:00
Shirone
58facb114c test(02-01): add PlanApprovalService tests
- 24 tests covering approval, rejection, timeout, cancellation, recovery
- Tests use Vitest fake timers for timeout testing
- Covers needsRecovery flag for server restart recovery
- Covers plan_rejected event emission
- Covers configurable timeout from project settings
2026-02-14 18:51:25 -08:00
Shirone
8387b7669d feat(02-01): create PlanApprovalService with timeout and recovery
- Extract plan approval workflow from AutoModeService
- Timeout-wrapped Promise creation via waitForApproval()
- Resolution handling (approve/reject) with needsRecovery flag
- Cancellation support for stopped features
- Per-project configurable timeout (default 30 minutes)
- Event emission through TypedEventBus for plan_rejected
2026-02-14 18:51:25 -08:00
Shirone
18fd1c6caa refactor(01-02): wire WorktreeResolver and FeatureStateManager into AutoModeService
- Add WorktreeResolver and FeatureStateManager as constructor parameters
- Remove top-level getCurrentBranch function (now in WorktreeResolver)
- Delegate loadFeature, updateFeatureStatus to FeatureStateManager
- Delegate markFeatureInterrupted, resetStuckFeatures to FeatureStateManager
- Delegate updateFeaturePlanSpec, saveFeatureSummary, updateTaskStatus
- Replace findExistingWorktreeForBranch calls with worktreeResolver
- Update tests to mock featureStateManager instead of internal methods
- All 89 tests passing across 3 service files
2026-02-14 18:51:25 -08:00
Shirone
6029e95403 feat(01-02): extract FeatureStateManager from AutoModeService
- Create FeatureStateManager class for feature status updates
- Extract updateFeatureStatus, markFeatureInterrupted, resetStuckFeatures
- Extract updateFeaturePlanSpec, saveFeatureSummary, updateTaskStatus
- Persist BEFORE emit pattern for data integrity (Pitfall 2)
- Handle corrupted JSON with readJsonWithRecovery backup support
- Preserve pipeline_* statuses in markFeatureInterrupted
- Fix bug: version increment now checks old content before applying updates
- Add 33 unit tests covering all state management operations
2026-02-14 18:51:25 -08:00
Shirone
1eb28206c5 refactor(01-03): wire TypedEventBus into AutoModeService
- Import TypedEventBus into AutoModeService
- Add eventBus property initialized via constructor injection
- Remove private emitAutoModeEvent method (now in TypedEventBus)
- Update all 66 emitAutoModeEvent calls to use this.eventBus
- Constructor accepts optional TypedEventBus for testing
2026-02-14 18:51:25 -08:00
Shirone
bc9dae0322 feat(01-02): extract WorktreeResolver from AutoModeService
- Create WorktreeResolver class for git worktree discovery
- Extract getCurrentBranch, findWorktreeForBranch, listWorktrees methods
- Add WorktreeInfo interface for worktree metadata
- Always resolve paths to absolute for cross-platform compatibility
- Add 20 unit tests covering all worktree operations
2026-02-14 18:51:25 -08:00
Shirone
3bcdc883e6 feat(01-03): create TypedEventBus class with tests
- Add TypedEventBus as wrapper around EventEmitter
- Implement emitAutoModeEvent method for auto-mode event format
- Add emit, subscribe, getUnderlyingEmitter methods
- Create comprehensive test suite (20 tests)
- Verify exact event format for frontend compatibility
2026-02-14 18:51:24 -08:00
Shirone
c92c8e96b7 refactor(01-01): wire ConcurrencyManager into AutoModeService
- AutoModeService now delegates to ConcurrencyManager for all running feature tracking
- Constructor accepts optional ConcurrencyManager for dependency injection
- Remove local RunningFeature interface (imported from ConcurrencyManager)
- Migrate all this.runningFeatures usages to concurrencyManager methods
- Update tests to use concurrencyManager.acquire() instead of direct Map access
- ConcurrencyManager accepts getCurrentBranch function for testability

BREAKING: AutoModeService no longer exposes runningFeatures Map directly.
Tests must use concurrencyManager.acquire() to add running features.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-14 18:51:24 -08:00
Shirone
b73ef9f801 feat(01-01): extract ConcurrencyManager class from AutoModeService
- Lease-based reference counting for nested execution support
- acquire() creates entry with leaseCount: 1 or increments existing
- release() decrements leaseCount, deletes at 0 or with force:true
- Project and worktree-level running counts
- RunningFeature interface exported for type sharing

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-14 18:51:24 -08:00
Shirone
70fc03431c test(01-01): add characterization tests for ConcurrencyManager
- Test lease counting basics (acquire/release semantics)
- Test running count queries (project and worktree level)
- Test feature state queries (isRunning, getRunningFeature, getAllRunning)
- Test edge cases (multiple features, multiple worktrees)
- 36 test cases documenting expected behavior

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-14 18:51:24 -08:00
Shirone
a0ea65d483 chore: ignore planning docs from version control
User preference: keep .planning/ local-only
2026-02-14 18:51:24 -08:00
Shirone
ef544e70c9 docs: initialize project
Refactoring auto-mode-service.ts (5k+ lines) into smaller, focused services with clear boundaries.
2026-02-14 18:51:23 -08:00
Shirone
152cf00735 docs: map existing codebase
- STACK.md - Technologies and dependencies
- ARCHITECTURE.md - System design and patterns
- STRUCTURE.md - Directory layout
- CONVENTIONS.md - Code style and patterns
- TESTING.md - Test structure
- INTEGRATIONS.md - External services
- CONCERNS.md - Technical debt and issues
2026-02-14 18:51:23 -08:00
DhanushSantosh
094f0809d7 chore: final dev commit
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-14 11:29:12 +05:30
Kacper
220c8e4ddf feat: add 'dev-server:url-detected' event type to EventType
- Introduced a new event type 'dev-server:url-detected' to enhance event handling for the development server.
- This addition allows for better tracking and response to URL detection during server operations.

These changes improve the event system's capability to manage server-related events effectively.
2026-02-05 23:19:31 +01:00
Kacper
f97453484f feat: enhance adaptive thinking model support and update UI components
- Added `isAdaptiveThinkingModel` utility to improve model identification logic in the AddFeatureDialog.
- Updated the ThinkingLevelSelector to conditionally display information based on available thinking levels.
- Enhanced model name formatting in agent-context-parser to include 'GPT-5.3 Codex' for better clarity.

These changes improve the user experience by refining model handling and UI feedback related to adaptive thinking capabilities.
2026-02-05 23:05:19 +01:00
Kacper
835ffe3185 feat: update Claude model to Opus 4.6 and enhance adaptive thinking support
- Changed model identifier from `claude-opus-4-5-20251101` to `claude-opus-4-6` across various files, including documentation and code references.
- Updated the SDK to support adaptive thinking for Opus 4.6, allowing the model to determine its own reasoning depth.
- Enhanced the thinking level options to include 'adaptive' and adjusted related components to reflect this change.
- Updated tests to ensure compatibility with the new model and its features.

These changes improve the model's capabilities and user experience by leveraging adaptive reasoning.
2026-02-05 22:43:22 +01:00
Kacper
3b361cb0b9 chore: update Codex SDK to version 0.98.0 and add GPT-5.3-Codex model
- Upgraded @openai/codex-sdk from version 0.77.0 to 0.98.0 in package-lock.json and package.json.
- Introduced new model 'GPT-5.3-Codex' with enhanced capabilities in codex-models.ts and related files.
- Updated descriptions for existing models to reflect their latest features and improvements.
- Adjusted Codex model configuration and display to include the new model and its attributes.

These changes enhance the Codex model offerings and ensure compatibility with the latest SDK version.
2026-02-05 22:17:55 +01:00
Kacper
0aef72540e fix(auth): Enhance credential detection logic for OAuth
- Updated getClaudeAuthIndicators() to ensure that empty or token-less credential files do not prevent the detection of valid credentials in subsequent paths.
- Improved error handling for settings file readability checks, providing clearer feedback on file access issues.
- Added unit tests to validate the new behavior, ensuring that the system continues to check all credential paths even when some files are empty or invalid.

This change improves the robustness of the credential detection process and enhances user experience by allowing for more flexible credential management.
2026-02-02 17:54:23 +01:00
Kacper
aad3ff2cdf fix(auth): Improve OAuth credential detection and startup warning
- Enhanced getClaudeAuthIndicators() to return detailed check information
  including file paths checked and specific error details for debugging
- Added debug logging to server startup credential detection for easier
  troubleshooting in Docker environments
- Show paths that were checked in the warning message to help users debug
  mount issues
- Added support for CLAUDE_CODE_OAUTH_TOKEN environment variable
- Return authType in verify-claude-auth response to distinguish between
  OAuth and CLI authentication methods
- Updated UI to show specific success messages for Claude Code subscription
  vs generic CLI auth
- Added Docker troubleshooting tips to sandbox risk dialog
- Added comprehensive unit tests for OAuth credential detection scenarios

Closes #721

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 17:35:03 +01:00
Kacper
3ccea7a67b fix(docker): Address remaining PR #745 review comments
- Move Playwright install after node_modules copy to use pinned version
- Use local playwright binary instead of npx to avoid registry fetch
- Add --user automaker -w /app flags to docker exec commands
- Change bold text to proper heading in README (MD036 lint fix)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 16:07:53 +01:00
Kacper
b37a287c9c fix(docker): Address PR #745 review feedback
- Clean up npx cache after Playwright installation to reduce image size
- Clarify README: volume mounts persist cache across container lifecycles,
  not image rebuilds
- Add first-use warning: empty volume overrides pre-installed browsers,
  users must re-install with docker exec command

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 15:55:11 +01:00
Kacper
45f6f17eb0 fix(docker): Pre-install Playwright Chromium browsers for automated testing
Fixes #725

AI agents in automated testing mode require Playwright to verify implementations,
but Docker containers had only system dependencies installed, not browser binaries.
This caused verification failures with permissions errors.

Changes:
- Install Playwright Chromium in Dockerfile (~300MB increase)
- Update docker-compose.override.yml.example with clearer Playwright documentation
- Add "Playwright for Automated Testing" section to README
- Document optional volume mount for persisting browsers across rebuilds

Browsers are now pre-installed and work out of the box for Docker users.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-02 15:47:18 +01:00
705 changed files with 107867 additions and 15748 deletions

14
.geminiignore Normal file
View File

@@ -0,0 +1,14 @@
# Auto-generated by Automaker to speed up Gemini CLI startup
# Prevents Gemini CLI from scanning large directories during context discovery
.git
node_modules
dist
build
.next
.nuxt
coverage
.automaker
.worktrees
.vscode
.idea
*.lock

View File

@@ -25,17 +25,24 @@ runs:
cache: 'npm'
cache-dependency-path: package-lock.json
- name: Check for SSH URLs in lockfile
if: inputs.check-lockfile == 'true'
shell: bash
run: npm run lint:lockfile
- name: Configure Git for HTTPS
shell: bash
# Convert SSH URLs to HTTPS for git dependencies (e.g., @electron/node-gyp)
# This is needed because SSH authentication isn't available in CI
run: git config --global url."https://github.com/".insteadOf "git@github.com:"
- name: Auto-fix SSH URLs in lockfile
if: inputs.check-lockfile == 'true'
shell: bash
# Auto-fix any git+ssh:// URLs in package-lock.json before linting
# This handles cases where npm reintroduces SSH URLs for git dependencies
run: node scripts/fix-lockfile-urls.mjs
- name: Check for SSH URLs in lockfile
if: inputs.check-lockfile == 'true'
shell: bash
run: npm run lint:lockfile
- name: Install dependencies
shell: bash
# Use npm install instead of npm ci to correctly resolve platform-specific
@@ -45,6 +52,7 @@ runs:
run: npm install --ignore-scripts --force
- name: Install Linux native bindings
if: runner.os == 'Linux'
shell: bash
# Workaround for npm optional dependencies bug (npm/cli#4828)
# Explicitly install Linux bindings needed for build tools

View File

@@ -13,6 +13,13 @@ jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
# shardIndex: [1, 2, 3]
# shardTotal: [3]
shardIndex: [1]
shardTotal: [1]
steps:
- name: Checkout code
@@ -46,7 +53,8 @@ jobs:
echo "SERVER_PID=$SERVER_PID" >> $GITHUB_ENV
env:
PORT: 3008
PORT: 3108
TEST_SERVER_PORT: 3108
NODE_ENV: test
# Use a deterministic API key so Playwright can log in reliably
AUTOMAKER_API_KEY: test-api-key-for-e2e-tests
@@ -81,16 +89,16 @@ jobs:
# Wait for health endpoint
for i in {1..60}; do
if curl -s -f http://localhost:3008/api/health > /dev/null 2>&1; then
if curl -s -f http://localhost:3108/api/health > /dev/null 2>&1; then
echo "Backend server is ready!"
echo "=== Backend logs ==="
cat backend.log
echo ""
echo "Health check response:"
curl -s http://localhost:3008/api/health | jq . 2>/dev/null || echo "Health check: $(curl -s http://localhost:3008/api/health 2>/dev/null || echo 'No response')"
curl -s http://localhost:3108/api/health | jq . 2>/dev/null || echo "Health check: $(curl -s http://localhost:3108/api/health 2>/dev/null || echo 'No response')"
exit 0
fi
# Check if server process is still running
if ! kill -0 $SERVER_PID 2>/dev/null; then
echo "ERROR: Server process died during wait!"
@@ -98,7 +106,7 @@ jobs:
cat backend.log
exit 1
fi
echo "Waiting... ($i/60)"
sleep 1
done
@@ -111,11 +119,11 @@ jobs:
ps aux | grep -E "(node|tsx)" | grep -v grep || echo "No node processes found"
echo ""
echo "=== Port status ==="
netstat -tlnp 2>/dev/null | grep :3008 || echo "Port 3008 not listening"
lsof -i :3008 2>/dev/null || echo "lsof not available or port not in use"
netstat -tlnp 2>/dev/null | grep :3108 || echo "Port 3108 not listening"
lsof -i :3108 2>/dev/null || echo "lsof not available or port not in use"
echo ""
echo "=== Health endpoint test ==="
curl -v http://localhost:3008/api/health 2>&1 || echo "Health endpoint failed"
curl -v http://localhost:3108/api/health 2>&1 || echo "Health endpoint failed"
# Kill the server process if it's still hanging
if kill -0 $SERVER_PID 2>/dev/null; then
@@ -126,16 +134,23 @@ jobs:
exit 1
- name: Run E2E tests
- name: Run E2E tests (shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
# Playwright automatically starts the Vite frontend via webServer config
# (see apps/ui/playwright.config.ts) - no need to start it manually
run: npm run test --workspace=apps/ui
run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
working-directory: apps/ui
env:
CI: true
VITE_SERVER_URL: http://localhost:3008
VITE_SKIP_SETUP: 'true'
# Keep UI-side login/defaults consistent
AUTOMAKER_API_KEY: test-api-key-for-e2e-tests
# Backend is already started above - Playwright config sets
# AUTOMAKER_SERVER_PORT so the Vite proxy forwards /api/* to the backend.
# Do NOT set VITE_SERVER_URL here: it bypasses the Vite proxy and causes
# a cookie domain mismatch (cookies are bound to 127.0.0.1, but
# VITE_SERVER_URL=http://localhost:3108 makes the frontend call localhost).
TEST_USE_EXTERNAL_BACKEND: 'true'
TEST_SERVER_PORT: 3108
- name: Print backend logs on failure
if: failure()
@@ -147,13 +162,13 @@ jobs:
ps aux | grep -E "(node|tsx)" | grep -v grep || echo "No node processes found"
echo ""
echo "=== Port status ==="
netstat -tlnp 2>/dev/null | grep :3008 || echo "Port 3008 not listening"
netstat -tlnp 2>/dev/null | grep :3108 || echo "Port 3108 not listening"
- name: Upload Playwright report
uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
name: playwright-report-shard-${{ matrix.shardIndex }}-of-${{ matrix.shardTotal }}
path: apps/ui/playwright-report/
retention-days: 7
@@ -161,12 +176,21 @@ jobs:
uses: actions/upload-artifact@v4
if: always()
with:
name: test-results
name: test-results-shard-${{ matrix.shardIndex }}-of-${{ matrix.shardTotal }}
path: |
apps/ui/test-results/
retention-days: 7
if-no-files-found: ignore
- name: Upload blob report for merging
uses: actions/upload-artifact@v4
if: always()
with:
name: blob-report-shard-${{ matrix.shardIndex }}-of-${{ matrix.shardTotal }}
path: apps/ui/blob-report/
retention-days: 1
if-no-files-found: ignore
- name: Cleanup - Kill backend server
if: always()
run: |

View File

@@ -95,9 +95,11 @@ jobs:
upload:
needs: build
runs-on: ubuntu-latest
if: github.event.release.draft == false
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Download macOS artifacts
uses: actions/download-artifact@v4
with:

19
.gitignore vendored
View File

@@ -65,6 +65,17 @@ coverage/
*.lcov
playwright-report/
blob-report/
test/**/test-project-[0-9]*/
test/opus-thinking-*/
test/agent-session-test-*/
test/feature-backlog-test-*/
test/running-task-display-test-*/
test/agent-output-modal-responsive-*/
test/fixtures/
test/board-bg-test-*/
test/edit-feature-test-*/
test/open-project-test-*/
# Environment files (keep .example)
.env
@@ -90,8 +101,16 @@ pnpm-lock.yaml
yarn.lock
# Fork-specific workflow files (should never be committed)
DEVELOPMENT_WORKFLOW.md
check-sync.sh
# API key files
data/.api-key
data/credentials.json
data/
.codex/
# GSD planning docs (local-only)
.planning/
.mcp.json
.planning
.bg-shell/

View File

@@ -38,6 +38,18 @@ else
export PATH="$PATH:/usr/local/bin:/opt/homebrew/bin:/usr/bin"
fi
# Auto-fix git+ssh:// URLs in package-lock.json if it's being committed
# This prevents CI failures from SSH URLs that npm introduces for git dependencies
if git diff --cached --name-only | grep -q "^package-lock.json$"; then
if command -v node >/dev/null 2>&1; then
if grep -q "git+ssh://" package-lock.json 2>/dev/null; then
echo "Fixing git+ssh:// URLs in package-lock.json..."
node scripts/fix-lockfile-urls.mjs
git add package-lock.json
fi
fi
fi
# Run lint-staged - works with or without nvm
# Prefer npx, fallback to npm exec, both work with system-installed Node.js
if command -v npx >/dev/null 2>&1; then

81
.planning/PROJECT.md Normal file
View File

@@ -0,0 +1,81 @@
# AutoModeService Refactoring
## What This Is
A comprehensive refactoring of the `auto-mode-service.ts` file (5k+ lines) into smaller, focused services with clear boundaries. This is an architectural cleanup of accumulated technical debt from rapid development, breaking the "god object" anti-pattern into maintainable, debuggable modules.
## Core Value
All existing auto-mode functionality continues working — features execute, pipelines flow, merges complete — while the codebase becomes maintainable.
## Requirements
### Validated
<!-- Existing functionality that must be preserved -->
- ✓ Single feature execution with AI agent — existing
- ✓ Concurrent execution with configurable limits — existing
- ✓ Pipeline orchestration (backlog → in-progress → approval → verified) — existing
- ✓ Git worktree isolation per feature — existing
- ✓ Automatic merging of completed work — existing
- ✓ Custom pipeline support — existing
- ✓ Test runner integration — existing
- ✓ Event streaming to frontend — existing
### Active
<!-- Refactoring goals -->
- [ ] No service file exceeds ~500 lines
- [ ] Each service has single, clear responsibility
- [ ] Service boundaries make debugging obvious
- [ ] Changes to one service don't risk breaking unrelated features
- [ ] Test coverage for critical paths
### Out of Scope
- New auto-mode features — this is cleanup, not enhancement
- UI changes — backend refactor only
- Performance optimization — maintain current performance, don't optimize
- Other service refactoring — focus on auto-mode-service.ts only
## Context
**Current state:** `apps/server/src/services/auto-mode-service.ts` is ~5700 lines handling:
- Worktree management (create, cleanup, track)
- Agent/task execution coordination
- Concurrency control and queue management
- Pipeline state machine (column transitions)
- Merge handling and conflict resolution
- Event emission for real-time updates
**Technical environment:**
- Express 5 backend, TypeScript
- Event-driven architecture via EventEmitter
- WebSocket streaming to React frontend
- Git worktrees via @automaker/git-utils
- Minimal existing test coverage
**Codebase analysis:** See `.planning/codebase/` for full architecture, conventions, and existing patterns.
## Constraints
- **Breaking changes**: Acceptable — other parts of the app can be updated to match new service interfaces
- **Test coverage**: Currently minimal — must add tests during refactoring to catch regressions
- **Incremental approach**: Required — can't do big-bang rewrite with everything critical
- **Existing patterns**: Follow conventions in `.planning/codebase/CONVENTIONS.md`
## Key Decisions
| Decision | Rationale | Outcome |
| ------------------------- | --------------------------------------------------- | --------- |
| Accept breaking changes | Allows cleaner interfaces, worth the migration cost | — Pending |
| Add tests during refactor | No existing safety net, need to build one | — Pending |
| Incremental extraction | Everything is critical, can't break it all at once | — Pending |
---
_Last updated: 2026-01-27 after initialization_

View File

@@ -0,0 +1,234 @@
# Architecture
**Analysis Date:** 2026-01-27
## Pattern Overview
**Overall:** Monorepo with layered client-server architecture (Electron-first) and pluggable provider abstraction for AI models.
**Key Characteristics:**
- Event-driven communication via WebSocket between frontend and backend
- Multi-provider AI model abstraction layer (Claude, Cursor, Codex, Gemini, OpenCode, Copilot)
- Feature-centric workflow stored in `.automaker/` directories
- Isolated git worktree execution for each feature
- State management through Zustand stores with API persistence
## Layers
**Presentation Layer (UI):**
- Purpose: React 19 Electron/web frontend with TanStack Router file-based routing
- Location: `apps/ui/src/`
- Contains: Route components, view pages, custom React hooks, Zustand stores, API client
- Depends on: @automaker/types, @automaker/utils, HTTP API backend
- Used by: Electron main process (desktop), web browser (web mode)
**API Layer (Server):**
- Purpose: Express 5 backend exposing RESTful and WebSocket endpoints
- Location: `apps/server/src/`
- Contains: Route handlers, business logic services, middleware, provider adapters
- Depends on: @automaker/types, @automaker/utils, @automaker/platform, Claude Agent SDK
- Used by: UI frontend via HTTP/WebSocket
**Service Layer (Server):**
- Purpose: Business logic and domain operations
- Location: `apps/server/src/services/`
- Contains: AgentService, FeatureLoader, AutoModeService, SettingsService, DevServerService, etc.
- Depends on: Providers, secure filesystem, feature storage
- Used by: Route handlers
**Provider Abstraction (Server):**
- Purpose: Unified interface for different AI model providers
- Location: `apps/server/src/providers/`
- Contains: ProviderFactory, specific provider implementations (ClaudeProvider, CursorProvider, CodexProvider, GeminiProvider, OpencodeProvider, CopilotProvider)
- Depends on: @automaker/types, provider SDKs
- Used by: AgentService
**Shared Library Layer:**
- Purpose: Type definitions and utilities shared across apps
- Location: `libs/`
- Contains: @automaker/types, @automaker/utils, @automaker/platform, @automaker/prompts, @automaker/model-resolver, @automaker/dependency-resolver, @automaker/git-utils, @automaker/spec-parser
- Depends on: None (types has no external deps)
- Used by: All apps and services
## Data Flow
**Feature Execution Flow:**
1. User creates/updates feature via UI (`apps/ui/src/`)
2. UI sends HTTP request to backend (`POST /api/features`)
3. Server route handler invokes FeatureLoader to persist to `.automaker/features/{featureId}/`
4. When executing, AgentService loads feature, creates isolated git worktree via @automaker/git-utils
5. AgentService invokes ProviderFactory to get appropriate AI provider (Claude, Cursor, etc.)
6. Provider executes with context from CLAUDE.md files via @automaker/utils loadContextFiles()
7. Server emits events via EventEmitter throughout execution
8. Events stream to frontend via WebSocket
9. UI updates stores and renders real-time progress
10. Feature results persist back to `.automaker/features/` with generated agent-output.md
**State Management:**
**Frontend State (Zustand):**
- `app-store.ts`: Global app state (projects, features, settings, boards, themes)
- `setup-store.ts`: First-time setup wizard flow
- `ideation-store.ts`: Ideation feature state
- `test-runners-store.ts`: Test runner configurations
- Settings now persist via API (`/api/settings`) rather than localStorage (see use-settings-sync.ts)
**Backend State (Services):**
- SettingsService: Global and project-specific settings (in-memory with file persistence)
- AgentService: Active agent sessions and conversation history
- FeatureLoader: Feature data model operations
- DevServerService: Development server logs
- EventHistoryService: Persists event logs for replay
**Real-Time Updates (WebSocket):**
- Server EventEmitter emits TypedEvent (type + payload)
- WebSocket handler subscribes to events and broadcasts to all clients
- Frontend listens on multiple WebSocket subscriptions and updates stores
## Key Abstractions
**Feature:**
- Purpose: Represents a development task/story with rich metadata
- Location: @automaker/types`libs/types/src/feature.ts`
- Fields: id, title, description, status, images, tasks, priority, etc.
- Stored: `.automaker/features/{featureId}/feature.json`
**Provider:**
- Purpose: Abstracts different AI model implementations
- Location: `apps/server/src/providers/{provider}-provider.ts`
- Interface: Common execute() method with consistent message format
- Implementations: Claude, Cursor, Codex, Gemini, OpenCode, Copilot
- Factory: ProviderFactory picks correct provider based on model ID
**Event:**
- Purpose: Real-time updates streamed to frontend
- Location: @automaker/types`libs/types/src/event.ts`
- Format: { type: EventType, payload: unknown }
- Examples: agent-started, agent-step, agent-complete, feature-updated, etc.
**AgentSession:**
- Purpose: Represents a conversation between user and AI agent
- Location: @automaker/types`libs/types/src/session.ts`
- Contains: Messages (user + assistant), metadata, creation timestamp
- Stored: `{DATA_DIR}/agent-sessions/{sessionId}.json`
**Settings:**
- Purpose: Configuration for global and per-project behavior
- Location: @automaker/types`libs/types/src/settings.ts`
- Stored: Global in `{DATA_DIR}/settings.json`, per-project in `.automaker/settings.json`
- Service: SettingsService in `apps/server/src/services/settings-service.ts`
## Entry Points
**Server:**
- Location: `apps/server/src/index.ts`
- Triggers: `npm run dev:server` or Docker startup
- Responsibilities:
- Initialize Express app with middleware
- Create shared EventEmitter for WebSocket streaming
- Bootstrap services (SettingsService, AgentService, FeatureLoader, etc.)
- Mount API routes at `/api/*`
- Create WebSocket servers for agent streaming and terminal sessions
- Load and apply user settings (log level, request logging, etc.)
**UI (Web):**
- Location: `apps/ui/src/main.ts` (Vite entry), `apps/ui/src/app.tsx` (React component)
- Triggers: `npm run dev:web` or `npm run build`
- Responsibilities:
- Initialize Zustand stores from API settings
- Setup React Router with TanStack Router
- Render root layout with sidebar and main content area
- Handle authentication via verifySession()
**UI (Electron):**
- Location: `apps/ui/src/main.ts` (Vite entry), `apps/ui/electron/main-process.ts` (Electron main process)
- Triggers: `npm run dev:electron`
- Responsibilities:
- Launch local server via node-pty
- Create native Electron window
- Bridge IPC between renderer and main process
- Provide file system access via preload.ts APIs
## Error Handling
**Strategy:** Layered error classification and user-friendly messaging
**Patterns:**
**Backend Error Handling:**
- Errors classified via `classifyError()` from @automaker/utils
- Classification: ParseError, NetworkError, AuthenticationError, RateLimitError, etc.
- Response format: `{ success: false, error: { type, message, code }, details? }`
- Example: `apps/server/src/lib/error-handler.ts`
**Frontend Error Handling:**
- HTTP errors caught by api-fetch.ts with retry logic
- WebSocket disconnects trigger reconnection with exponential backoff
- Errors shown in toast notifications via `sonner` library
- Validation errors caught and displayed inline in forms
**Agent Execution Errors:**
- AgentService wraps provider calls in try-catch
- Aborts handled specially via `isAbortError()` check
- Rate limit errors trigger cooldown before retry
- Model-specific errors mapped to user guidance
## Cross-Cutting Concerns
**Logging:**
- Framework: @automaker/utils createLogger()
- Pattern: `const logger = createLogger('ModuleName')`
- Levels: ERROR, WARN, INFO, DEBUG (configurable via settings)
- Output: stdout (dev), files (production)
**Validation:**
- File path validation: @automaker/platform initAllowedPaths() enforces restrictions
- Model ID validation: @automaker/model-resolver resolveModelString()
- JSON schema validation: Manual checks in route handlers (no JSON schema lib)
- Authentication: Session token validation via validateWsConnectionToken()
**Authentication:**
- Frontend: Session token stored in httpOnly cookie
- Backend: authMiddleware checks token on protected routes
- WebSocket: validateWsConnectionToken() for upgrade requests
- Providers: API keys stored encrypted in `{DATA_DIR}/credentials.json`
**Internationalization:**
- Not detected - strings are English-only
**Performance:**
- Code splitting: File-based routing via TanStack Router
- Lazy loading: React.lazy() in route components
- Caching: React Query for HTTP requests (query-keys.ts defines cache strategy)
- Image optimization: Automatic base64 encoding for agent context
- State hydration: Settings loaded once at startup, synced via API
---
_Architecture analysis: 2026-01-27_

View File

@@ -0,0 +1,245 @@
# Codebase Concerns
**Analysis Date:** 2026-01-27
## Tech Debt
**Loose Type Safety in Error Handling:**
- Issue: Multiple uses of `as any` type assertions bypass TypeScript safety, particularly in error context handling and provider responses
- Files: `apps/server/src/providers/claude-provider.ts` (lines 318-322), `apps/server/src/lib/error-handler.ts`, `apps/server/src/routes/settings/routes/update-global.ts`
- Impact: Errors could have unchecked properties; refactoring becomes risky without compiler assistance
- Fix approach: Replace `as any` with proper type guards and discriminated unions; create helper functions for safe property access
**Missing Test Coverage for Critical Services:**
- Issue: Several core services explicitly excluded from test coverage thresholds due to integration complexity
- Files: `apps/server/vitest.config.ts` (line 22), explicitly excluded: `claude-usage-service.ts`, `mcp-test-service.ts`, `cli-provider.ts`, `cursor-provider.ts`
- Impact: Usage tracking, MCP integration, and CLI detection could break undetected; regression detection is limited
- Fix approach: Create integration test fixtures for CLI providers; mock MCP SDK for mcp-test-service tests; add usage tracking unit tests with mocked API calls
**Unused/Stub TODO Item Processing:**
- Issue: TodoWrite tool implementation exists but is partially integrated; tool name constants scattered across codex provider
- Files: `apps/server/src/providers/codex-tool-mapping.ts`, `apps/server/src/providers/codex-provider.ts`
- Impact: Todo list updates may not synchronize properly with all providers; unclear which providers support TodoWrite
- Fix approach: Consolidate tool name constants; add provider capability flags for todo support
**Electron Electron.ts Size and Complexity:**
- Issue: Single 3741-line file handles all Electron IPC, native bindings, and communication
- Files: `apps/ui/src/lib/electron.ts`
- Impact: Difficult to test; hard to isolate bugs; changes require full testing of all features; potential memory overhead from monolithic file
- Fix approach: Split by responsibility (IPC, window management, file operations, debug tools); create separate bridge layers
## Known Bugs
**API Key Management Incomplete for Gemini:**
- Symptoms: Gemini API key verification endpoint not implemented despite other providers having verification
- Files: `apps/ui/src/components/views/settings-view/api-keys/hooks/use-api-key-management.ts` (line 122)
- Trigger: User tries to verify Gemini API key in settings
- Workaround: Key verification skipped for Gemini; settings page still accepts and stores key
**Orphaned Features Detection Vulnerable to False Negatives:**
- Symptoms: Features marked as orphaned when branch matching logic doesn't account for all scenarios
- Files: `apps/server/src/services/auto-mode-service.ts` (lines 5714-5773)
- Trigger: Features that were manually switched branches or rebased
- Workaround: Manual cleanup via feature deletion; branch comparison is basic name matching only
**Terminal Themes Incomplete:**
- Symptoms: Light theme themes (solarizedlight, github) map to same generic lightTheme; no dedicated implementations
- Files: `apps/ui/src/config/terminal-themes.ts` (lines 593-594)
- Trigger: User selects solarizedlight or github terminal theme
- Workaround: Uses generic light theme instead of specific scheme; visual appearance doesn't match expectation
## Security Considerations
**Process Environment Variable Exposure:**
- Risk: Child processes inherit all parent `process.env` including sensitive credentials (API keys, tokens)
- Files: `apps/server/src/providers/cursor-provider.ts` (line 993), `apps/server/src/providers/codex-provider.ts` (line 1099)
- Current mitigation: Dotenv provides isolation at app startup; selective env passing to some providers
- Recommendations: Use explicit allowlists for env vars passed to child processes (only pass REQUIRED_KEYS); audit all spawn calls for env handling; document which providers need which credentials
**Unvalidated Provider Tool Input:**
- Risk: Tool input from CLI providers (Cursor, Copilot, Codex) is partially validated through Record<string, unknown> patterns; execution context could be escaped
- Files: `apps/server/src/providers/codex-provider.ts` (lines 506-543), `apps/server/src/providers/tool-normalization.ts`
- Current mitigation: Status enums validated; tool names checked against allow-lists in some providers
- Recommendations: Implement comprehensive schema validation for all tool inputs before execution; use zod or similar for runtime validation; add security tests for injection patterns
**API Key Storage in Settings Files:**
- Risk: API keys stored in plaintext in `~/.automaker/settings.json` and `data/settings.json`; file permissions may not be restricted
- Files: `apps/server/src/services/settings-service.ts`, uses `atomicWriteJson` without file permission enforcement
- Current mitigation: Limited by file system permissions; Electron mode has single-user access
- Recommendations: Encrypt sensitive settings fields (apiKeys, tokens); use OS credential stores (Keychain/Credential Manager) for production; add file permission checks on startup
## Performance Bottlenecks
**Synchronous Feature Loading at Startup:**
- Problem: All features loaded synchronously at project load; blocks UI with 1000+ features
- Files: `apps/server/src/services/feature-loader.ts` (line 230 Promise.all, but synchronous enumeration)
- Cause: Feature directory walk and JSON parsing is not paginated or lazy-loaded
- Improvement path: Implement lazy loading with pagination (load first 50, fetch more on scroll); add caching layer with TTL; move to background indexing; add feature count limits with warnings
**Auto-Mode Concurrency at Max Can Exceed Rate Limits:**
- Problem: maxConcurrency = 10 can quickly exhaust Claude API rate limits if all features execute simultaneously
- Files: `apps/server/src/services/auto-mode-service.ts` (line 2931 Promise.all for concurrent agents)
- Cause: No adaptive backoff; no API usage tracking before queuing; hint mentions reducing concurrency but doesn't enforce it
- Improvement path: Integrate with claude-usage-service to check remaining quota before starting features; implement exponential backoff on 429 errors; add per-model rate limit tracking
**Terminal Session Memory Leak Risk:**
- Problem: Terminal sessions accumulate in memory; expired sessions not cleaned up reliably
- Files: `apps/server/src/routes/terminal/common.ts` (line 66 cleanup runs every 5 minutes, but only for tokens)
- Cause: Cleanup interval is arbitrary; session map not bounded; no session lifespan limit
- Improvement path: Implement LRU eviction with max session count; reduce cleanup interval to 1 minute; add memory usage monitoring; auto-close idle sessions after 30 minutes
**Large File Content Loading Without Limits:**
- Problem: File content loaded entirely into memory; `describe-file.ts` truncates at 50KB but loads all content first
- Files: `apps/server/src/routes/context/routes/describe-file.ts` (line 128)
- Cause: Synchronous file read; no streaming; no check before reading large files
- Improvement path: Check file size before reading; stream large files; add file size warnings; implement chunked processing for analysis
## Fragile Areas
**Provider Factory Model Resolution:**
- Files: `apps/server/src/providers/provider-factory.ts`, `apps/server/src/providers/simple-query-service.ts`
- Why fragile: Each provider interprets model strings differently; no central registry; model aliases resolved at multiple layers (model-resolver, provider-specific maps, CLI validation)
- Safe modification: Add integration tests for each model alias per provider; create model capability matrix; centralize model validation before dispatch
- Test coverage: No dedicated tests; relies on E2E; no isolated unit tests for model resolution
**WebSocket Session Authentication:**
- Files: `apps/server/src/lib/auth.ts` (line 40 setInterval), `apps/server/src/index.ts` (token validation per message)
- Why fragile: Session tokens generated and validated at multiple points; no single source of truth; expiration is not atomic
- Safe modification: Add tests for token expiration edge cases; ensure cleanup removes all references; log all auth failures
- Test coverage: Auth middleware tested, but not session lifecycle
**Auto-Mode Feature State Machine:**
- Files: `apps/server/src/services/auto-mode-service.ts` (lines 465-600)
- Why fragile: Multiple states (running, queued, completed, error) managed across different methods; no explicit state transition validation; error recovery is defensive (catches all, logs, continues)
- Safe modification: Create explicit state enum with valid transitions; add invariant checks; unit test state transitions with all error cases
- Test coverage: Gaps in error recovery paths; no tests for concurrent state changes
## Scaling Limits
**Feature Count Scalability:**
- Current capacity: ~1000 features tested; UI performance degrades with pagination required
- Limit: 10K+ features cause >5s load times; memory usage ~100MB for metadata alone
- Scaling path: Implement feature database instead of file-per-feature; add ElasticSearch indexing for search; paginate API responses (50 per page); add feature archiving
**Concurrent Auto-Mode Executions:**
- Current capacity: maxConcurrency = 10 features; limited by Claude API rate limits
- Limit: Rate limit hits at ~4-5 simultaneous features with extended context (100K+ tokens)
- Scaling path: Implement token usage budgeting before feature start; queue features with estimated token cost; add provider-specific rate limit handling
**Terminal Session Count:**
- Current capacity: ~100 active terminal sessions per server
- Limit: Memory grows unbounded; no session count limit enforced
- Scaling path: Add max session count with least-recently-used eviction; implement session federation for distributed setup
**Worktree Disk Usage:**
- Current capacity: 10K worktrees (~20GB with typical repos)
- Limit: `.worktrees` directory grows without cleanup; old worktrees accumulate
- Scaling path: Add worktree TTL (delete if not used for 30 days); implement cleanup job; add quota warnings at 50/80% disk
## Dependencies at Risk
**node-pty Beta Version:**
- Risk: `node-pty@1.1.0-beta41` used for terminal emulation; beta status indicates possible instability
- Impact: Terminal features could break on minor platform changes; no guarantees on bug fixes
- Migration plan: Monitor releases for stable version; pin to specific commit if needed; test extensively on target platforms (macOS, Linux, Windows)
**@anthropic-ai/claude-agent-sdk 0.1.x:**
- Risk: Pre-1.0 version; SDK API may change in future releases; limited version stability guarantees
- Impact: Breaking changes could require significant refactoring; feature additions in SDK may not align with Automaker roadmap
- Migration plan: Pin to specific 0.1.x version; review SDK changelogs before upgrades; maintain SDK compatibility tests; consider fallback implementation for critical paths
**@openai/codex-sdk 0.77.x:**
- Risk: Codex model deprecated by OpenAI; SDK may be archived or unsupported
- Impact: Codex provider could become non-functional; error messages may not be actionable
- Migration plan: Monitor OpenAI roadmap for migration path; implement fallback to Claude for Codex requests; add deprecation warning in UI
**Express 5.2.x RC Stage:**
- Risk: Express 5 is still in release candidate phase (as of Node 22); full stability not guaranteed
- Impact: Minor version updates could include breaking changes; middleware compatibility issues possible
- Migration plan: Maintain compatibility layer for Express 5 API; test with latest major before release; document any version-specific workarounds
## Missing Critical Features
**Persistent Session Storage:**
- Problem: Agent conversation sessions stored only in-memory; restart loses all chat history
- Blocks: Long-running analysis across server restarts; session recovery not possible
- Impact: Users must re-run entire analysis if server restarts; lost productivity
**Rate Limit Awareness:**
- Problem: No tracking of API usage relative to rate limits before executing features
- Blocks: Predictable concurrent feature execution; users frequently hit rate limits unexpectedly
- Impact: Feature execution fails with cryptic rate limit errors; poor user experience
**Feature Dependency Visualization:**
- Problem: Dependency-resolver package exists but no UI to visualize or manage dependencies
- Blocks: Users cannot plan feature order; complex dependencies not visible
- Impact: Features implemented in wrong order; blocking dependencies missed
## Test Coverage Gaps
**CLI Provider Integration:**
- What's not tested: Actual CLI execution paths; environment setup; error recovery from CLI crashes
- Files: `apps/server/src/providers/cli-provider.ts`, `apps/server/src/lib/cli-detection.ts`
- Risk: Changes to CLI handling could break silently; detection logic not validated on target platforms
- Priority: High - affects all CLI-based providers (Cursor, Copilot, Codex)
**Cursor Provider Platform-Specific Paths:**
- What's not tested: Windows/Linux Cursor installation detection; version directory parsing; APPDATA environment variable handling
- Files: `apps/server/src/providers/cursor-provider.ts` (lines 267-498)
- Risk: Platform-specific bugs not caught; Cursor detection fails on non-standard installations
- Priority: High - Cursor is primary provider; platform differences critical
**Event Hook System State Changes:**
- What's not tested: Concurrent hook execution; cleanup on server shutdown; webhook delivery retries
- Files: `apps/server/src/services/event-hook-service.ts` (line 248 Promise.allSettled)
- Risk: Hooks may not execute in expected order; memory not cleaned up; webhooks lost on failure
- Priority: Medium - affects automation workflows
**Error Classification for New Providers:**
- What's not tested: Each provider's unique error patterns mapped to ErrorType enum; new provider errors not classified
- Files: `apps/server/src/lib/error-handler.ts` (lines 58-80), each provider error mapping
- Risk: User sees generic "unknown error" instead of actionable message; categorization regresses with new providers
- Priority: Medium - impacts user experience
**Feature State Corruption Scenarios:**
- What's not tested: Concurrent feature updates; partial writes with power loss; JSON parsing recovery
- Files: `apps/server/src/services/feature-loader.ts`, `@automaker/utils` (atomicWriteJson)
- Risk: Feature data corrupted on concurrent access; recovery incomplete; no validation before use
- Priority: High - data loss risk
---
_Concerns audit: 2026-01-27_

View File

@@ -0,0 +1,255 @@
# Coding Conventions
**Analysis Date:** 2026-01-27
## Naming Patterns
**Files:**
- PascalCase for class/service files: `auto-mode-service.ts`, `feature-loader.ts`, `claude-provider.ts`
- kebab-case for route/handler directories: `auto-mode/`, `features/`, `event-history/`
- kebab-case for utility files: `secure-fs.ts`, `sdk-options.ts`, `settings-helpers.ts`
- kebab-case for React components: `card.tsx`, `ansi-output.tsx`, `count-up-timer.tsx`
- kebab-case for hooks: `use-board-background-settings.ts`, `use-responsive-kanban.ts`, `use-test-logs.ts`
- kebab-case for store files: `app-store.ts`, `auth-store.ts`, `setup-store.ts`
- Organized by functionality: `routes/features/routes/list.ts`, `routes/features/routes/get.ts`
**Functions:**
- camelCase for all function names: `createEventEmitter()`, `getAutomakerDir()`, `executeQuery()`
- Verb-first for action functions: `buildPrompt()`, `classifyError()`, `loadContextFiles()`, `atomicWriteJson()`
- Prefix with `use` for React hooks: `useBoardBackgroundSettings()`, `useAppStore()`, `useUpdateProjectSettings()`
- Private methods prefixed with underscore: `_deleteOrphanedImages()`, `_migrateImages()`
**Variables:**
- camelCase for constants and variables: `featureId`, `projectPath`, `modelId`, `tempDir`
- UPPER_SNAKE_CASE for global constants/enums: `DEFAULT_MAX_CONCURRENCY`, `DEFAULT_PHASE_MODELS`
- Meaningful naming over abbreviations: `featureDirectory` not `fd`, `featureImages` not `img`
- Prefixes for computed values: `is*` for booleans: `isClaudeModel`, `isContainerized`, `isAutoLoginEnabled`
**Types:**
- PascalCase for interfaces and types: `Feature`, `ExecuteOptions`, `EventEmitter`, `ProviderConfig`
- Type files suffixed with `.d.ts`: `paths.d.ts`, `types.d.ts`
- Organized by domain: `src/store/types/`, `src/lib/`
- Re-export pattern from main package indexes: `export type { Feature };`
## Code Style
**Formatting:**
- Tool: Prettier 3.7.4
- Print width: 100 characters
- Tab width: 2 spaces
- Single quotes for strings
- Semicolons required
- Trailing commas: es5 (trailing in arrays/objects, not in params)
- Arrow functions always include parentheses: `(x) => x * 2`
- Line endings: LF (Unix)
- Bracket spacing: `{ key: value }`
**Linting:**
- Tool: ESLint (flat config in `apps/ui/eslint.config.mjs`)
- TypeScript ESLint plugin for `.ts`/`.tsx` files
- Recommended configs: `@eslint/js`, `@typescript-eslint/recommended`
- Unused variables warning with exception for parameters starting with `_`
- Type assertions are allowed with description when using `@ts-ignore`
- `@typescript-eslint/no-explicit-any` is warn-level (allow with caution)
## Import Organization
**Order:**
1. Node.js standard library: `import fs from 'fs/promises'`, `import path from 'path'`
2. Third-party packages: `import { describe, it } from 'vitest'`, `import { Router } from 'express'`
3. Shared packages (monorepo): `import type { Feature } from '@automaker/types'`, `import { createLogger } from '@automaker/utils'`
4. Local relative imports: `import { FeatureLoader } from './feature-loader.js'`, `import * as secureFs from '../lib/secure-fs.js'`
5. Type imports: separated with `import type { ... } from`
**Path Aliases:**
- `@/` - resolves to `./src` in both UI (`apps/ui/`) and server (`apps/server/`)
- Shared packages prefixed with `@automaker/`:
- `@automaker/types` - core TypeScript definitions
- `@automaker/utils` - logging, errors, utilities
- `@automaker/prompts` - AI prompt templates
- `@automaker/platform` - path management, security, processes
- `@automaker/model-resolver` - model alias resolution
- `@automaker/dependency-resolver` - feature dependency ordering
- `@automaker/git-utils` - git operations
- Extensions: `.js` extension used in imports for ESM imports
**Import Rules:**
- Always import from shared packages, never from old paths
- No circular dependencies between layers
- Services import from providers and utilities
- Routes import from services
- Shared packages have strict dependency hierarchy (types → utils → platform → git-utils → server/ui)
## Error Handling
**Patterns:**
- Use `try-catch` blocks for async operations: wraps feature execution, file operations, git commands
- Throw `new Error(message)` with descriptive messages: `throw new Error('already running')`, `throw new Error('Feature ${featureId} not found')`
- Classify errors with `classifyError()` from `@automaker/utils` for categorization
- Log errors with context using `createLogger()`: includes error classification
- Return error info objects: `{ valid: false, errors: [...], warnings: [...] }`
- Validation returns structured result: `{ valid, errors, warnings }` from provider `validateConfig()`
**Error Types:**
- Authentication errors: distinguish from validation/runtime errors
- Path validation errors: caught by middleware in Express routes
- File system errors: logged and recovery attempted with backups
- SDK/API errors: classified and wrapped with context
- Abort/cancellation errors: handled without stack traces (graceful shutdown)
**Error Messages:**
- Descriptive and actionable: not vague error codes
- Include context when helpful: file paths, feature IDs, model names
- User-friendly messages via `getUserFriendlyErrorMessage()` for client display
## Logging
**Framework:**
- Built-in `createLogger()` from `@automaker/utils`
- Each module creates logger: `const logger = createLogger('ModuleName')`
- Logger functions: `info()`, `warn()`, `error()`, `debug()`
**Patterns:**
- Log operation start and completion for significant operations
- Log warnings for non-critical issues: file deletion failures, missing optional configs
- Log errors with full error object: `logger.error('operation failed', error)`
- Use module name as logger context: `createLogger('AutoMode')`, `createLogger('HttpClient')`
- Avoid logging sensitive data (API keys, passwords)
- No console.log in production code - use logger
**What to Log:**
- Feature execution start/completion
- Error classification and recovery attempts
- File operations (create, delete, migrate)
- API calls and responses (in debug mode)
- Async operation start/end
- Warnings for deprecated patterns
## Comments
**When to Comment:**
- Complex algorithms or business logic: explain the "why" not the "what"
- Integration points: explain how modules communicate
- Workarounds: explain the constraint that made the workaround necessary
- Non-obvious performance implications
- Edge cases and their handling
**JSDoc/TSDoc:**
- Used for public functions and classes
- Document parameters with `@param`
- Document return types with `@returns`
- Document exceptions with `@throws`
- Used for service classes: `/**\n * Module description\n * Manages: ...\n */`
- Not required for simple getters/setters
**Example JSDoc Pattern:**
```typescript
/**
* Delete images that were removed from a feature
*/
private async deleteOrphanedImages(
projectPath: string,
oldPaths: Array<string>,
newPaths: Array<string>
): Promise<void> {
// Implementation
}
```
## Function Design
**Size:**
- Keep functions under 100 lines when possible
- Large services split into multiple related methods
- Private helper methods extracted for complex logic
**Parameters:**
- Use destructuring for object parameters with multiple properties
- Document parameter types with TypeScript types
- Optional parameters marked with `?`
- Use `Record<string, unknown>` for flexible object parameters
**Return Values:**
- Explicit return types required for all public functions
- Return structured objects for multiple values
- Use `Promise<T>` for async functions
- Async generators use `AsyncGenerator<T>` for streaming responses
- Never implicitly return `undefined` (explicit return or throw)
## Module Design
**Exports:**
- Default export for class instantiation: `export default class FeatureLoader {}`
- Named exports for functions: `export function createEventEmitter() {}`
- Type exports separated: `export type { Feature };`
- Barrel files (index.ts) re-export from module
**Barrel Files:**
- Used in routes: `routes/features/index.ts` creates router and exports
- Used in stores: `store/index.ts` exports all store hooks
- Pattern: group related exports for easier importing
**Service Classes:**
- Instantiated once and dependency injected
- Public methods for API surface
- Private methods prefixed with `_`
- No static methods - prefer instances or functions
- Constructor takes dependencies: `constructor(config?: ProviderConfig)`
**Provider Pattern:**
- Abstract base class: `BaseProvider` with abstract methods
- Concrete implementations: `ClaudeProvider`, `CodexProvider`, `CursorProvider`
- Common interface: `executeQuery()`, `detectInstallation()`, `validateConfig()`
- Factory for instantiation: `ProviderFactory.create()`
## TypeScript Specific
**Strict Mode:** Always enabled globally
- `strict: true` in all tsconfigs
- No implicit `any` - declare types explicitly
- No optional chaining on base types without narrowing
**Type Definitions:**
- Interface for shapes: `interface Feature { ... }`
- Type for unions/aliases: `type ModelAlias = 'haiku' | 'sonnet' | 'opus'`
- Type guards for narrowing: `if (typeof x === 'string') { ... }`
- Generic types for reusable patterns: `EventCallback<T>`
**React Specific (UI):**
- Functional components only
- React 19 with hooks
- Type props interface: `interface CardProps extends React.ComponentProps<'div'> { ... }`
- Zustand stores for state management
- Custom hooks for shared logic
---
_Convention analysis: 2026-01-27_

View File

@@ -0,0 +1,232 @@
# External Integrations
**Analysis Date:** 2026-01-27
## APIs & External Services
**AI/LLM Providers:**
- Claude (Anthropic)
- SDK: `@anthropic-ai/claude-agent-sdk` (0.1.76)
- Auth: `ANTHROPIC_API_KEY` environment variable or stored credentials
- Features: Extended thinking, vision/images, tools, streaming
- Implementation: `apps/server/src/providers/claude-provider.ts`
- Models: Opus 4.5, Sonnet 4, Haiku 4.5, and legacy models
- Custom endpoints: `ANTHROPIC_BASE_URL` (optional)
- GitHub Copilot
- SDK: `@github/copilot-sdk` (0.1.16)
- Auth: GitHub OAuth (via `gh` CLI) or `GITHUB_TOKEN` environment variable
- Features: Tools, streaming, runtime model discovery
- Implementation: `apps/server/src/providers/copilot-provider.ts`
- CLI detection: Searches for Copilot CLI binary
- Models: Dynamic discovery via `copilot models list`
- OpenAI Codex/GPT-4
- SDK: `@openai/codex-sdk` (0.77.0)
- Auth: `OPENAI_API_KEY` environment variable or stored credentials
- Features: Extended thinking, tools, sandbox execution
- Implementation: `apps/server/src/providers/codex-provider.ts`
- Execution modes: CLI (with sandbox) or SDK (direct API)
- Models: Dynamic discovery via Codex CLI or SDK
- Google Gemini
- Implementation: `apps/server/src/providers/gemini-provider.ts`
- Features: Vision support, tools, streaming
- OpenCode (AWS/Azure/other)
- Implementation: `apps/server/src/providers/opencode-provider.ts`
- Supports: Amazon Bedrock, Azure models, local models
- Features: Flexible provider architecture
- Cursor Editor
- Implementation: `apps/server/src/providers/cursor-provider.ts`
- Features: Integration with Cursor IDE
**Model Context Protocol (MCP):**
- SDK: `@modelcontextprotocol/sdk` (1.25.2)
- Purpose: Connect AI agents to external tools and data sources
- Implementation: `apps/server/src/services/mcp-test-service.ts`, `apps/server/src/routes/mcp/`
- Configuration: Per-project in `.automaker/` directory
## Data Storage
**Databases:**
- None - This codebase does NOT use traditional databases (SQL/NoSQL)
- All data stored as files in local filesystem
**File Storage:**
- Local filesystem only
- Locations:
- `.automaker/` - Project-specific data (features, context, settings)
- `./data/` or `DATA_DIR` env var - Global data (settings, credentials, sessions)
- Secure file operations: `@automaker/platform` exports `secureFs` for restricted file access
**Caching:**
- In-memory caches for:
- Model lists (Copilot, Codex runtime discovery)
- Feature metadata
- Project specifications
- No distributed/persistent caching system
## Authentication & Identity
**Auth Provider:**
- Custom implementation (no third-party provider)
- Authentication methods:
1. Claude Max Plan (OAuth via Anthropic CLI)
2. API Key mode (ANTHROPIC_API_KEY)
3. Custom provider profiles with API keys
4. Token-based session authentication for WebSocket
**Implementation:**
- `apps/server/src/lib/auth.ts` - Auth middleware
- `apps/server/src/routes/auth/` - Auth routes
- Session tokens for WebSocket connections
- Credential storage in `./data/credentials.json` (encrypted/protected)
## Monitoring & Observability
**Error Tracking:**
- None - No automatic error reporting service integrated
- Custom error classification: `@automaker/utils` exports `classifyError()`
- User-friendly error messages: `getUserFriendlyErrorMessage()`
**Logs:**
- Console logging with configurable levels
- Logger: `@automaker/utils` exports `createLogger()`
- Log levels: ERROR, WARN, INFO, DEBUG
- Environment: `LOG_LEVEL` env var (optional)
- Storage: Logs output to console/stdout (no persistent logging to files)
**Usage Tracking:**
- Claude API usage: `apps/server/src/services/claude-usage-service.ts`
- Codex API usage: `apps/server/src/services/codex-usage-service.ts`
- Tracks: Tokens, costs, rates
## CI/CD & Deployment
**Hosting:**
- Local development: Node.js server + Vite dev server
- Desktop: Electron application (macOS, Windows, Linux)
- Web: Express server deployed to any Node.js host
**CI Pipeline:**
- GitHub Actions likely (`.github/workflows/` present in repo)
- Testing: Playwright E2E, Vitest unit tests
- Linting: ESLint
- Formatting: Prettier
**Build Process:**
- `npm run build:packages` - Build shared packages
- `npm run build` - Build web UI
- `npm run build:electron` - Build Electron apps (platform-specific)
- Electron Builder handles code signing and distribution
## Environment Configuration
**Required env vars:**
- `ANTHROPIC_API_KEY` - For Claude provider (or provide in settings)
- `OPENAI_API_KEY` - For Codex provider (optional)
- `GITHUB_TOKEN` - For GitHub operations (optional)
**Optional env vars:**
- `PORT` - Server port (default 3008)
- `HOST` - Server bind address (default 0.0.0.0)
- `HOSTNAME` - Public hostname (default localhost)
- `DATA_DIR` - Data storage directory (default ./data)
- `ANTHROPIC_BASE_URL` - Custom Claude endpoint
- `ALLOWED_ROOT_DIRECTORY` - Restrict file operations to directory
- `AUTOMAKER_MOCK_AGENT` - Enable mock agent for testing
- `AUTOMAKER_AUTO_LOGIN` - Skip login prompt in dev
**Secrets location:**
- Runtime: Environment variables (`process.env`)
- Stored: `./data/credentials.json` (file-based)
- Retrieval: `apps/server/src/services/settings-service.ts`
## Webhooks & Callbacks
**Incoming:**
- WebSocket connections for real-time agent event streaming
- GitHub webhook routes (optional): `apps/server/src/routes/github/`
- Terminal WebSocket connections: `apps/server/src/routes/terminal/`
**Outgoing:**
- GitHub PRs: `apps/server/src/routes/worktree/routes/create-pr.ts`
- Git operations: `@automaker/git-utils` handles commits, pushes
- Terminal output streaming via WebSocket to clients
- Event hooks: `apps/server/src/services/event-hook-service.ts`
## Credential Management
**API Keys Storage:**
- File: `./data/credentials.json`
- Format: JSON with nested structure for different providers
```json
{
"apiKeys": {
"anthropic": "sk-...",
"openai": "sk-...",
"github": "ghp_..."
}
}
```
- Access: `SettingsService.getCredentials()` from `apps/server/src/services/settings-service.ts`
- Security: File permissions should restrict to current user only
**Profile/Provider Configuration:**
- File: `./data/settings.json` (global) or `.automaker/settings.json` (per-project)
- Stores: Alternative provider profiles, model mappings, sandbox settings
- Types: `ClaudeApiProfile`, `ClaudeCompatibleProvider` from `@automaker/types`
## Third-Party Service Integration Points
**Git/GitHub:**
- `@automaker/git-utils` - Git operations (worktrees, commits, diffs)
- Codex/Cursor providers can create GitHub PRs
- GitHub CLI (`gh`) detection for Copilot authentication
**Terminal Access:**
- `node-pty` (1.1.0-beta41) - Pseudo-terminal interface
- `TerminalService` manages terminal sessions
- WebSocket streaming to frontend
**AI Models - Multi-Provider Abstraction:**
- `BaseProvider` interface: `apps/server/src/providers/base-provider.ts`
- Factory pattern: `apps/server/src/providers/provider-factory.ts`
- Allows swapping providers without changing agent logic
- All providers implement: `executeQuery()`, `detectInstallation()`, `getAvailableModels()`
**Process Spawning:**
- `@automaker/platform` exports `spawnProcess()`, `spawnJSONLProcess()`
- Codex CLI execution: JSONL output parsing
- Copilot CLI execution: Subprocess management
- Cursor IDE interaction: Process spawning for tool execution
---
_Integration audit: 2026-01-27_

230
.planning/codebase/STACK.md Normal file
View File

@@ -0,0 +1,230 @@
# Technology Stack
**Analysis Date:** 2026-01-27
## Languages
**Primary:**
- TypeScript 5.9.3 - Used across all packages, apps, and configuration
- JavaScript (Node.js) - Runtime execution for scripts and tooling
**Secondary:**
- YAML 2.7.0 - Configuration files
- CSS/Tailwind CSS 4.1.18 - Frontend styling
## Runtime
**Environment:**
- Node.js 22.x (>=22.0.0 <23.0.0) - Required version, specified in `.nvmrc`
**Package Manager:**
- npm - Monorepo workspace management via npm workspaces
- Lockfile: `package-lock.json` (present)
## Frameworks
**Core - Frontend:**
- React 19.2.3 - UI framework with hooks and concurrent features
- Vite 7.3.0 - Build tool and dev server (`apps/ui/vite.config.ts`)
- Electron 39.2.7 - Desktop application runtime (`apps/ui/package.json`)
- TanStack Router 1.141.6 - File-based routing (React)
- Zustand 5.0.9 - State management (lightweight alternative to Redux)
- TanStack Query (React Query) 5.90.17 - Server state management
**Core - Backend:**
- Express 5.2.1 - HTTP server framework (`apps/server/package.json`)
- WebSocket (ws) 8.18.3 - Real-time bidirectional communication
- Claude Agent SDK (@anthropic-ai/claude-agent-sdk) 0.1.76 - AI provider integration
**Testing:**
- Playwright 1.57.0 - End-to-end testing (`apps/ui` E2E tests)
- Vitest 4.0.16 - Unit testing framework (runs on all packages and server)
- @vitest/ui 4.0.16 - Visual test runner UI
- @vitest/coverage-v8 4.0.16 - Code coverage reporting
**Build/Dev:**
- electron-builder 26.0.12 - Electron app packaging and distribution
- @vitejs/plugin-react 5.1.2 - Vite React support
- vite-plugin-electron 0.29.0 - Vite plugin for Electron main process
- vite-plugin-electron-renderer 0.14.6 - Vite plugin for Electron renderer
- ESLint 9.39.2 - Code linting (`apps/ui`)
- @typescript-eslint/eslint-plugin 8.50.0 - TypeScript ESLint rules
- Prettier 3.7.4 - Code formatting (root-level config)
- Tailwind CSS 4.1.18 - Utility-first CSS framework
- @tailwindcss/vite 4.1.18 - Tailwind Vite integration
**UI Components & Libraries:**
- Radix UI - Unstyled accessible component library (@radix-ui packages)
- react-dropdown-menu 2.1.16
- react-dialog 1.1.15
- react-select 2.2.6
- react-tooltip 1.2.8
- react-tabs 1.1.13
- react-collapsible 1.1.12
- react-checkbox 1.3.3
- react-radio-group 1.3.8
- react-popover 1.1.15
- react-slider 1.3.6
- react-switch 1.2.6
- react-scroll-area 1.2.10
- react-label 2.1.8
- Lucide React 0.562.0 - Icon library
- Geist 1.5.1 - Design system UI library
- Sonner 2.0.7 - Toast notifications
**Code Editor & Terminal:**
- @uiw/react-codemirror 4.25.4 - Code editor React component
- CodeMirror (@codemirror packages) 6.x - Editor toolkit
- xterm.js (@xterm/xterm) 5.5.0 - Terminal emulator
- @xterm/addon-fit 0.10.0 - Fit addon for terminal
- @xterm/addon-search 0.15.0 - Search addon for terminal
- @xterm/addon-web-links 0.11.0 - Web links addon
- @xterm/addon-webgl 0.18.0 - WebGL renderer for terminal
**Diagram/Graph Visualization:**
- @xyflow/react 12.10.0 - React flow diagram library
- dagre 0.8.5 - Graph layout algorithms
**Markdown/Content Rendering:**
- react-markdown 10.1.0 - Markdown parser and renderer
- remark-gfm 4.0.1 - GitHub Flavored Markdown support
- rehype-raw 7.0.0 - Raw HTML support in markdown
- rehype-sanitize 6.0.0 - HTML sanitization
**Data Validation & Parsing:**
- zod 3.24.1 or 4.0.0 - Schema validation and TypeScript type inference
**Utilities:**
- class-variance-authority 0.7.1 - CSS variant utilities
- clsx 2.1.1 - Conditional className utility
- cmdk 1.1.1 - Command menu/palette
- tailwind-merge 3.4.0 - Tailwind CSS conflict resolution
- usehooks-ts 3.1.1 - TypeScript React hooks
- @dnd-kit (drag-and-drop) 6.3.1 - Drag and drop library
**Font Libraries:**
- @fontsource - Web font packages (Cascadia Code, Fira Code, IBM Plex, Inconsolata, Inter, etc.)
**Development Utilities:**
- cross-spawn 7.0.6 - Cross-platform process spawning
- dotenv 17.2.3 - Environment variable loading
- tsx 4.21.0 - TypeScript execution for Node.js
- tree-kill 1.2.2 - Process tree killer utility
- node-pty 1.1.0-beta41 - PTY/terminal interface for Node.js
## Key Dependencies
**Critical - AI/Agent Integration:**
- @anthropic-ai/claude-agent-sdk 0.1.76 - Core Claude AI provider
- @github/copilot-sdk 0.1.16 - GitHub Copilot integration
- @openai/codex-sdk 0.77.0 - OpenAI Codex/GPT-4 integration
- @modelcontextprotocol/sdk 1.25.2 - Model Context Protocol servers
**Infrastructure - Internal Packages:**
- @automaker/types 1.0.0 - Shared TypeScript type definitions
- @automaker/utils 1.0.0 - Logging, error handling, utilities
- @automaker/platform 1.0.0 - Path management, security, process spawning
- @automaker/prompts 1.0.0 - AI prompt templates
- @automaker/model-resolver 1.0.0 - Claude model alias resolution
- @automaker/dependency-resolver 1.0.0 - Feature dependency ordering
- @automaker/git-utils 1.0.0 - Git operations & worktree management
- @automaker/spec-parser 1.0.0 - Project specification parsing
**Server Utilities:**
- express 5.2.1 - Web framework
- cors 2.8.5 - CORS middleware
- morgan 1.10.1 - HTTP request logger
- cookie-parser 1.4.7 - Cookie parsing middleware
- yaml 2.7.0 - YAML parsing and generation
**Type Definitions:**
- @types/express 5.0.6
- @types/node 22.19.3
- @types/react 19.2.7
- @types/react-dom 19.2.3
- @types/dagre 0.7.53
- @types/ws 8.18.1
- @types/cookie 0.6.0
- @types/cookie-parser 1.4.10
- @types/cors 2.8.19
- @types/morgan 1.9.10
**Optional Dependencies (Platform-specific):**
- lightningcss (various platforms) 1.29.2 - CSS parser (alternate to PostCSS)
- dmg-license 1.0.11 - DMG license dialog for macOS
## Configuration
**Environment:**
- `.env` and `.env.example` files in `apps/server/` and `apps/ui/`
- `dotenv` library loads variables from `.env` files
- Key env vars:
- `ANTHROPIC_API_KEY` - Claude API authentication
- `OPENAI_API_KEY` - OpenAI/Codex authentication
- `GITHUB_TOKEN` - GitHub API access
- `ANTHROPIC_BASE_URL` - Custom Claude endpoint (optional)
- `HOST` - Server bind address (default: 0.0.0.0)
- `HOSTNAME` - Hostname for URLs (default: localhost)
- `PORT` - Server port (default: 3008)
- `DATA_DIR` - Data storage directory (default: ./data)
- `ALLOWED_ROOT_DIRECTORY` - Restrict file operations
- `AUTOMAKER_MOCK_AGENT` - Enable mock agent for testing
- `AUTOMAKER_AUTO_LOGIN` - Skip login in dev (disabled in production)
- `VITE_HOSTNAME` - Frontend API hostname
**Build:**
- `apps/ui/electron-builder.config.json` or `apps/ui/package.json` build config
- Electron builder targets:
- macOS: DMG and ZIP
- Windows: NSIS installer
- Linux: AppImage, DEB, RPM
- Vite config: `apps/ui/vite.config.ts`, `apps/server/tsconfig.json`
- TypeScript config: `tsconfig.json` files in each package
## Platform Requirements
**Development:**
- Node.js 22.x
- npm (included with Node.js)
- Git (for worktree operations)
- Python (optional, for some dev scripts)
**Production:**
- Electron desktop app: Windows, macOS, Linux
- Web browser: Modern Chromium-based browsers
- Server: Any platform supporting Node.js 22.x
**Deployment Target:**
- Local desktop (Electron)
- Local web server (Express + Vite)
- Remote server deployment (Docker, systemd, or other orchestration)
---
_Stack analysis: 2026-01-27_

View File

@@ -0,0 +1,340 @@
# Codebase Structure
**Analysis Date:** 2026-01-27
## Directory Layout
```
automaker/
├── apps/ # Application packages
│ ├── ui/ # React + Electron frontend (port 3007)
│ │ ├── src/
│ │ │ ├── main.ts # Electron/Vite entry point
│ │ │ ├── app.tsx # Root React component (splash, router)
│ │ │ ├── renderer.tsx # Electron renderer entry
│ │ │ ├── routes/ # TanStack Router file-based routes
│ │ │ ├── components/ # React components (views, dialogs, UI, layout)
│ │ │ ├── store/ # Zustand state management
│ │ │ ├── hooks/ # Custom React hooks
│ │ │ ├── lib/ # Utilities (API client, electron, queries, etc.)
│ │ │ ├── electron/ # Electron main & preload process files
│ │ │ ├── config/ # UI configuration (fonts, themes, routes)
│ │ │ └── styles/ # CSS and theme files
│ │ ├── public/ # Static assets
│ │ └── tests/ # E2E Playwright tests
│ │
│ └── server/ # Express backend (port 3008)
│ ├── src/
│ │ ├── index.ts # Express app initialization, route mounting
│ │ ├── routes/ # REST API endpoints (30+ route folders)
│ │ ├── services/ # Business logic services
│ │ ├── providers/ # AI model provider implementations
│ │ ├── lib/ # Utilities (events, auth, helpers, etc.)
│ │ ├── middleware/ # Express middleware
│ │ └── types/ # Server-specific type definitions
│ └── tests/ # Unit tests (Vitest)
├── libs/ # Shared npm packages (@automaker/*)
│ ├── types/ # @automaker/types (no dependencies)
│ │ └── src/
│ │ ├── index.ts # Main export with all type definitions
│ │ ├── feature.ts # Feature, FeatureStatus, etc.
│ │ ├── provider.ts # Provider interfaces, model definitions
│ │ ├── settings.ts # Global and project settings types
│ │ ├── event.ts # Event types for real-time updates
│ │ ├── session.ts # AgentSession, conversation types
│ │ ├── model*.ts # Model-specific types (cursor, codex, gemini, etc.)
│ │ └── ... 20+ more type files
│ │
│ ├── utils/ # @automaker/utils (logging, errors, images, context)
│ │ └── src/
│ │ ├── logger.ts # createLogger() with LogLevel enum
│ │ ├── errors.ts # classifyError(), error types
│ │ ├── image-utils.ts # Image processing, base64 encoding
│ │ ├── context-loader.ts # loadContextFiles() for AI prompts
│ │ └── ... more utilities
│ │
│ ├── platform/ # @automaker/platform (paths, security, OS)
│ │ └── src/
│ │ ├── index.ts # Path getters (getFeatureDir, getFeaturesDir, etc.)
│ │ ├── secure-fs.ts # Secure filesystem operations
│ │ └── config/ # Claude auth detection, allowed paths
│ │
│ ├── prompts/ # @automaker/prompts (AI prompt templates)
│ │ └── src/
│ │ ├── index.ts # Main prompts export
│ │ └── *-prompt.ts # Prompt templates for different features
│ │
│ ├── model-resolver/ # @automaker/model-resolver
│ │ └── src/
│ │ └── index.ts # resolveModelString() for model aliases
│ │
│ ├── dependency-resolver/ # @automaker/dependency-resolver
│ │ └── src/
│ │ └── index.ts # Resolve feature dependencies
│ │
│ ├── git-utils/ # @automaker/git-utils (git operations)
│ │ └── src/
│ │ ├── index.ts # getGitRepositoryDiffs(), worktree management
│ │ └── ... git helpers
│ │
│ ├── spec-parser/ # @automaker/spec-parser
│ │ └── src/
│ │ └── ... spec parsing utilities
│ │
│ └── tsconfig.base.json # Base TypeScript config for all packages
├── .automaker/ # Project data directory (created by app)
│ ├── features/ # Feature storage
│ │ └── {featureId}/
│ │ ├── feature.json # Feature metadata and content
│ │ ├── agent-output.md # Agent execution results
│ │ └── images/ # Feature images
│ ├── context/ # Context files (CLAUDE.md, etc.)
│ ├── settings.json # Per-project settings
│ ├── spec.md # Project specification
│ └── analysis.json # Project structure analysis
├── data/ # Global data directory (default, configurable)
│ ├── settings.json # Global settings, profiles
│ ├── credentials.json # Encrypted API keys
│ ├── sessions-metadata.json # Chat session metadata
│ └── agent-sessions/ # Conversation histories
├── .planning/ # Generated documentation by GSD orchestrator
│ └── codebase/ # Codebase analysis documents
│ ├── ARCHITECTURE.md # Architecture patterns and layers
│ ├── STRUCTURE.md # This file
│ ├── STACK.md # Technology stack
│ ├── INTEGRATIONS.md # External API integrations
│ ├── CONVENTIONS.md # Code style and naming
│ ├── TESTING.md # Testing patterns
│ └── CONCERNS.md # Technical debt and issues
├── .github/ # GitHub Actions workflows
├── scripts/ # Build and utility scripts
├── tests/ # Test data and utilities
├── docs/ # Documentation
├── package.json # Root workspace config
├── package-lock.json # Lock file
├── CLAUDE.md # Project instructions for Claude Code
├── DEVELOPMENT_WORKFLOW.md # Development guidelines
└── README.md # Project overview
```
## Directory Purposes
**apps/ui/:**
- Purpose: React frontend for desktop (Electron) and web modes
- Build system: Vite 7 with TypeScript
- Styling: Tailwind CSS 4
- State: Zustand 5 with API persistence
- Routing: TanStack Router with file-based structure
- Desktop: Electron 39 with preload IPC bridge
**apps/server/:**
- Purpose: Express backend API and service layer
- Build system: TypeScript → JavaScript
- Runtime: Node.js 18+
- WebSocket: ws library for real-time streaming
- Process management: node-pty for terminal isolation
**libs/types/:**
- Purpose: Central type definitions (no dependencies, fast import)
- Used by: All other packages and apps
- Pattern: Single namespace export from index.ts
- Build: Compiled to ESM only
**libs/utils/:**
- Purpose: Shared utilities for logging, errors, file operations, image processing
- Used by: Server, UI, other libraries
- Notable: `createLogger()`, `classifyError()`, `loadContextFiles()`, `readImageAsBase64()`
**libs/platform/:**
- Purpose: OS-agnostic path management and security enforcement
- Used by: Server services for file operations
- Notable: Path normalization, allowed directory enforcement, Claude auth detection
**libs/prompts/:**
- Purpose: AI prompt templates injected into agent context
- Used by: AgentService when executing features
- Pattern: Function exports that return prompt strings
## Key File Locations
**Entry Points:**
**Server:**
- `apps/server/src/index.ts`: Express server initialization, route mounting, WebSocket setup
**UI (Web):**
- `apps/ui/src/main.ts`: Vite entry point
- `apps/ui/src/app.tsx`: Root React component
**UI (Electron):**
- `apps/ui/src/main.ts`: Vite entry point
- `apps/ui/src/electron/main-process.ts`: Electron main process
- `apps/ui/src/preload.ts`: Electron preload script for IPC bridge
**Configuration:**
- `apps/server/src/index.ts`: PORT, HOST, HOSTNAME, DATA_DIR env vars
- `apps/ui/src/config/`: Theme options, fonts, model aliases
- `libs/types/src/settings.ts`: Settings schema
- `.env.local`: Local development overrides (git-ignored)
**Core Logic:**
**Server:**
- `apps/server/src/services/agent-service.ts`: AI agent execution engine (31KB)
- `apps/server/src/services/auto-mode-service.ts`: Feature batching and automation (216KB - largest)
- `apps/server/src/services/feature-loader.ts`: Feature persistence and loading
- `apps/server/src/services/settings-service.ts`: Settings management
- `apps/server/src/providers/provider-factory.ts`: AI provider selection
**UI:**
- `apps/ui/src/store/app-store.ts`: Global state (84KB - largest frontend file)
- `apps/ui/src/lib/http-api-client.ts`: API client with auth (92KB)
- `apps/ui/src/components/views/board-view.tsx`: Kanban board (70KB)
- `apps/ui/src/routes/__root.tsx`: Root layout with session init (32KB)
**Testing:**
**E2E Tests:**
- `apps/ui/tests/`: Playwright tests organized by feature area
- `settings/`, `features/`, `projects/`, `agent/`, `utils/`, `context/`
**Unit Tests:**
- `libs/*/tests/`: Package-specific Vitest tests
- `apps/server/src/tests/`: Server integration tests
**Test Config:**
- `vitest.config.ts`: Root Vitest configuration
- `apps/ui/playwright.config.ts`: Playwright configuration
## Naming Conventions
**Files:**
- **Components:** PascalCase.tsx (e.g., `board-view.tsx`, `session-manager.tsx`)
- **Services:** camelCase-service.ts (e.g., `agent-service.ts`, `settings-service.ts`)
- **Hooks:** use-kebab-case.ts (e.g., `use-auto-mode.ts`, `use-settings-sync.ts`)
- **Utilities:** camelCase.ts (e.g., `api-fetch.ts`, `log-parser.ts`)
- **Routes:** kebab-case with index.ts pattern (e.g., `routes/agent/index.ts`)
- **Tests:** _.test.ts or _.spec.ts (co-located with source)
**Directories:**
- **Feature domains:** kebab-case (e.g., `auto-mode/`, `event-history/`, `project-settings-view/`)
- **Type categories:** kebab-case plural (e.g., `types/`, `services/`, `providers/`, `routes/`)
- **Shared utilities:** kebab-case (e.g., `lib/`, `utils/`, `hooks/`)
**TypeScript:**
- **Types:** PascalCase (e.g., `Feature`, `AgentSession`, `ProviderMessage`)
- **Interfaces:** PascalCase (e.g., `EventEmitter`, `ProviderFactory`)
- **Enums:** PascalCase (e.g., `LogLevel`, `FeatureStatus`)
- **Functions:** camelCase (e.g., `createLogger()`, `classifyError()`)
- **Constants:** UPPER_SNAKE_CASE (e.g., `DEFAULT_TIMEOUT_MS`, `MAX_RETRIES`)
- **Variables:** camelCase (e.g., `featureId`, `settingsService`)
## Where to Add New Code
**New Feature (end-to-end):**
- API Route: `apps/server/src/routes/{feature-name}/index.ts`
- Service Logic: `apps/server/src/services/{feature-name}-service.ts`
- UI Route: `apps/ui/src/routes/{feature-name}.tsx` (simple) or `{feature-name}/` (complex with subdir)
- Store: `apps/ui/src/store/{feature-name}-store.ts` (if complex state)
- Tests: `apps/ui/tests/{feature-name}/` or `apps/server/src/tests/`
**New Component/Module:**
- View Components: `apps/ui/src/components/views/{component-name}/`
- Dialog Components: `apps/ui/src/components/dialogs/{dialog-name}.tsx`
- Shared Components: `apps/ui/src/components/shared/` or `components/ui/` (shadcn)
- Layout Components: `apps/ui/src/components/layout/`
**Utilities:**
- New Library: Create in `libs/{package-name}/` with package.json and tsconfig.json
- Server Utilities: `apps/server/src/lib/{utility-name}.ts`
- Shared Utilities: Extend `libs/utils/src/` or create new lib if self-contained
- UI Utilities: `apps/ui/src/lib/{utility-name}.ts`
**New Provider (AI Model):**
- Implementation: `apps/server/src/providers/{provider-name}-provider.ts`
- Types: Add to `libs/types/src/{provider-name}-models.ts`
- Model Resolver: Update `libs/model-resolver/src/index.ts` with model alias mapping
- Settings: Update `libs/types/src/settings.ts` for provider-specific config
## Special Directories
**apps/ui/electron/:**
- Purpose: Electron-specific code (main process, IPC handlers, native APIs)
- Generated: Yes (preload.ts)
- Committed: Yes
**apps/ui/public/**
- Purpose: Static assets (sounds, images, icons)
- Generated: No
- Committed: Yes
**apps/ui/dist/:**
- Purpose: Built web application
- Generated: Yes
- Committed: No (.gitignore)
**apps/ui/dist-electron/:**
- Purpose: Built Electron app bundle
- Generated: Yes
- Committed: No (.gitignore)
**.automaker/features/{featureId}/:**
- Purpose: Per-feature persistent storage
- Structure: feature.json, agent-output.md, images/
- Generated: Yes (at runtime)
- Committed: Yes (tracked in project git)
**data/:**
- Purpose: Global data directory (global settings, credentials, sessions)
- Generated: Yes (created at first run)
- Committed: No (.gitignore)
- Configurable: Via DATA_DIR env var
**node_modules/:**
- Purpose: Installed dependencies
- Generated: Yes
- Committed: No (.gitignore)
**dist/**, **build/:**
- Purpose: Build artifacts
- Generated: Yes
- Committed: No (.gitignore)
---
_Structure analysis: 2026-01-27_

View File

@@ -0,0 +1,389 @@
# Testing Patterns
**Analysis Date:** 2026-01-27
## Test Framework
**Runner:**
- Vitest 4.0.16 (for unit and integration tests)
- Playwright (for E2E tests)
- Config: `apps/server/vitest.config.ts`, `libs/*/vitest.config.ts`, `apps/ui/playwright.config.ts`
**Assertion Library:**
- Vitest built-in expect assertions
- API: `expect().toBe()`, `expect().toEqual()`, `expect().toHaveLength()`, `expect().toHaveProperty()`
**Run Commands:**
```bash
npm run test # E2E tests (Playwright, headless)
npm run test:headed # E2E tests with browser visible
npm run test:packages # All shared package unit tests (vitest)
npm run test:server # Server unit tests (vitest run)
npm run test:server:coverage # Server tests with coverage report
npm run test:all # All tests (packages + server)
npm run test:unit # Vitest run (all projects)
npm run test:unit:watch # Vitest watch mode
```
## Test File Organization
**Location:**
- Co-located with source: `src/module.ts` has `tests/unit/module.test.ts`
- Server tests: `apps/server/tests/` (separate directory)
- Library tests: `libs/*/tests/` (each package)
- E2E tests: `apps/ui/tests/` (Playwright)
**Naming:**
- Pattern: `{moduleName}.test.ts` for unit tests
- Pattern: `{moduleName}.spec.ts` for specification tests
- Glob pattern: `tests/**/*.test.ts`, `tests/**/*.spec.ts`
**Structure:**
```
apps/server/
├── tests/
│ ├── setup.ts # Global test setup
│ ├── unit/
│ │ ├── providers/ # Provider tests
│ │ │ ├── claude-provider.test.ts
│ │ │ ├── codex-provider.test.ts
│ │ │ └── base-provider.test.ts
│ │ └── services/
│ └── utils/
│ └── helpers.ts # Test utilities
└── src/
libs/platform/
├── tests/
│ ├── paths.test.ts
│ ├── security.test.ts
│ ├── subprocess.test.ts
│ └── node-finder.test.ts
└── src/
```
## Test Structure
**Suite Organization:**
```typescript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { FeatureLoader } from '@/services/feature-loader.js';
describe('feature-loader.ts', () => {
let featureLoader: FeatureLoader;
beforeEach(() => {
vi.clearAllMocks();
featureLoader = new FeatureLoader();
});
afterEach(async () => {
// Cleanup resources
});
describe('methodName', () => {
it('should do specific thing', () => {
expect(result).toBe(expected);
});
});
});
```
**Patterns:**
- Setup pattern: `beforeEach()` initializes test instance, clears mocks
- Teardown pattern: `afterEach()` cleans up temp directories, removes created files
- Assertion pattern: one logical assertion per test (or multiple closely related)
- Test isolation: each test runs with fresh setup
## Mocking
**Framework:**
- Vitest `vi` module: `vi.mock()`, `vi.mocked()`, `vi.clearAllMocks()`
- Mock patterns: module mocking, function spying, return value mocking
**Patterns:**
Module mocking:
```typescript
vi.mock('@anthropic-ai/claude-agent-sdk');
// In test:
vi.mocked(sdk.query).mockReturnValue(
(async function* () {
yield { type: 'text', text: 'Response 1' };
})()
);
```
Async generator mocking (for streaming APIs):
```typescript
const generator = provider.executeQuery({
prompt: 'Hello',
model: 'claude-opus-4-5-20251101',
cwd: '/test',
});
const results = await collectAsyncGenerator(generator);
```
Partial mocking with spies:
```typescript
const provider = new TestProvider();
const spy = vi.spyOn(provider, 'getName');
spy.mockReturnValue('mocked-name');
```
**What to Mock:**
- External APIs (Claude SDK, GitHub SDK, cloud services)
- File system operations (use temp directories instead when possible)
- Network calls
- Process execution
- Time-dependent operations
**What NOT to Mock:**
- Core business logic (test the actual implementation)
- Type definitions
- Internal module dependencies (test integration with real services)
- Standard library functions (fs, path, etc. - use fixtures instead)
## Fixtures and Factories
**Test Data:**
```typescript
// Test helper for collecting async generator results
async function collectAsyncGenerator<T>(generator: AsyncGenerator<T>): Promise<T[]> {
const results: T[] = [];
for await (const item of generator) {
results.push(item);
}
return results;
}
// Temporary directory fixture
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'test-'));
projectPath = path.join(tempDir, 'test-project');
await fs.mkdir(projectPath, { recursive: true });
});
afterEach(async () => {
try {
await fs.rm(tempDir, { recursive: true, force: true });
} catch (error) {
// Ignore cleanup errors
}
});
```
**Location:**
- Inline in test files for simple fixtures
- `tests/utils/helpers.ts` for shared test utilities
- Factory functions for complex test objects: `createTestProvider()`, `createMockFeature()`
## Coverage
**Requirements (Server):**
- Lines: 60%
- Functions: 75%
- Branches: 55%
- Statements: 60%
- Config: `apps/server/vitest.config.ts` with thresholds
**Excluded from Coverage:**
- Route handlers: tested via integration/E2E tests
- Type re-exports
- Middleware: tested via integration tests
- Prompt templates
- MCP integration: awaits MCP SDK integration tests
- Provider CLI integrations: awaits integration tests
**View Coverage:**
```bash
npm run test:server:coverage # Generate coverage report
# Opens HTML report in: apps/server/coverage/index.html
```
**Coverage Tools:**
- Provider: v8
- Reporters: text, json, html, lcov
- File inclusion: `src/**/*.ts`
- File exclusion: `src/**/*.d.ts`, specific service files in thresholds
## Test Types
**Unit Tests:**
- Scope: Individual functions and methods
- Approach: Test inputs → outputs with mocked dependencies
- Location: `apps/server/tests/unit/`
- Examples:
- Provider executeQuery() with mocked SDK
- Path construction functions with assertions
- Error classification with different error types
- Config validation with various inputs
**Integration Tests:**
- Scope: Multiple modules working together
- Approach: Test actual service calls with real file system or temp directories
- Pattern: Setup data → call method → verify results
- Example: Feature loader reading/writing feature.json files
- Example: Auto-mode service coordinating with multiple services
**E2E Tests:**
- Framework: Playwright
- Scope: Full user workflows from UI
- Location: `apps/ui/tests/`
- Config: `apps/ui/playwright.config.ts`
- Setup:
- Backend server with mock agent enabled
- Frontend Vite dev server
- Sequential execution (workers: 1) to avoid auth conflicts
- Screenshots/traces on failure
- Auth: Global setup authentication in `tests/global-setup.ts`
- Fixtures: `tests/e2e-fixtures/` for test project data
## Common Patterns
**Async Testing:**
```typescript
it('should execute async operation', async () => {
const result = await featureLoader.loadFeature(projectPath, featureId);
expect(result).toBeDefined();
expect(result.id).toBe(featureId);
});
// For streams/generators:
const generator = provider.executeQuery({ prompt, model, cwd });
const results = await collectAsyncGenerator(generator);
expect(results).toHaveLength(2);
```
**Error Testing:**
```typescript
it('should throw error when feature not found', async () => {
await expect(featureLoader.getFeature(projectPath, 'nonexistent')).rejects.toThrow('not found');
});
// Testing error classification:
const errorInfo = classifyError(new Error('ENOENT'));
expect(errorInfo.category).toBe('FileSystem');
```
**Fixture Setup:**
```typescript
it('should create feature with images', async () => {
// Setup: create temp feature directory
const featureDir = path.join(projectPath, '.automaker', 'features', featureId);
await fs.mkdir(featureDir, { recursive: true });
// Act: perform operation
const result = await featureLoader.updateFeature(projectPath, {
id: featureId,
imagePaths: ['/temp/image.png'],
});
// Assert: verify file operations
const migratedPath = path.join(featureDir, 'images', 'image.png');
expect(fs.existsSync(migratedPath)).toBe(true);
});
```
**Mock Reset Pattern:**
```typescript
// In vitest.config.ts:
mockReset: true, // Reset all mocks before each test
restoreMocks: true, // Restore original implementations
clearMocks: true, // Clear mock call history
// In test:
beforeEach(() => {
vi.clearAllMocks();
delete process.env.ANTHROPIC_API_KEY;
});
```
## Test Configuration
**Vitest Config Patterns:**
Server config (`apps/server/vitest.config.ts`):
- Environment: node
- Globals: true (describe/it without imports)
- Setup files: `./tests/setup.ts`
- Alias resolution: resolves `@automaker/*` to source files for mocking
Library config:
- Simpler setup: just environment and globals
- Coverage with high thresholds (90%+ lines)
**Global Setup:**
```typescript
// tests/setup.ts
import { vi, beforeEach } from 'vitest';
process.env.NODE_ENV = 'test';
process.env.DATA_DIR = '/tmp/test-data';
beforeEach(() => {
vi.clearAllMocks();
});
```
## Testing Best Practices
**Isolation:**
- Each test is independent (no state sharing)
- Cleanup temp files in afterEach
- Reset mocks and environment variables in beforeEach
**Clarity:**
- Descriptive test names: "should do X when Y condition"
- One logical assertion per test
- Clear arrange-act-assert structure
**Speed:**
- Mock external services
- Use in-memory temp directories
- Avoid real network calls
- Sequential E2E tests to prevent conflicts
**Maintainability:**
- Use beforeEach/afterEach for common setup
- Extract test helpers to `tests/utils/`
- Keep test data simple and local
- Mock consistently across tests
---
_Testing analysis: 2026-01-27_

View File

@@ -161,7 +161,7 @@ Use `resolveModelString()` from `@automaker/model-resolver` to convert model ali
- `haiku``claude-haiku-4-5`
- `sonnet``claude-sonnet-4-20250514`
- `opus``claude-opus-4-5-20251101`
- `opus``claude-opus-4-6`
## Environment Variables

View File

@@ -1,253 +0,0 @@
# Development Workflow
This document defines the standard workflow for keeping a branch in sync with the upstream
release candidate (RC) and for shipping feature work. It is paired with `check-sync.sh`.
## Quick Decision Rule
1. Ask the user to select a workflow:
- **Sync Workflow** → you are maintaining the current RC branch with fixes/improvements
and will push the same fixes to both origin and upstream RC when you have local
commits to publish.
- **PR Workflow** → you are starting new feature work on a new branch; upstream updates
happen via PR only.
2. After the user selects, run:
```bash
./check-sync.sh
```
3. Use the status output to confirm alignment. If it reports **diverged**, default to
merging `upstream/<TARGET_RC>` into the current branch and preserving local commits.
For Sync Workflow, when the working tree is clean and you are behind upstream RC,
proceed with the fetch + merge without asking for additional confirmation.
## Target RC Resolution
The target RC is resolved dynamically so the workflow stays current as the RC changes.
Resolution order:
1. Latest `upstream/v*rc` branch (auto-detected)
2. `upstream/HEAD` (fallback)
3. If neither is available, you must pass `--rc <branch>`
Override for a single run:
```bash
./check-sync.sh --rc <rc-branch>
```
## Pre-Flight Checklist
1. Confirm a clean working tree:
```bash
git status
```
2. Confirm the current branch:
```bash
git branch --show-current
```
3. Ensure remotes exist (origin + upstream):
```bash
git remote -v
```
## Sync Workflow (Upstream Sync)
Use this flow when you are updating the current branch with fixes or improvements and
intend to keep origin and upstream RC in lockstep.
1. **Check sync status**
```bash
./check-sync.sh
```
2. **Update from upstream RC before editing (no pulls)**
- **Behind upstream RC** → fetch and merge RC into your branch:
```bash
git fetch upstream
git merge upstream/<TARGET_RC> --no-edit
```
When the working tree is clean and the user selected Sync Workflow, proceed without
an extra confirmation prompt.
- **Diverged** → stop and resolve manually.
3. **Resolve conflicts if needed**
- Handle conflicts intelligently: preserve upstream behavior and your local intent.
4. **Make changes and commit (if you are delivering fixes)**
```bash
git add -A
git commit -m "type: description"
```
5. **Build to verify**
```bash
npm run build:packages
npm run build
```
6. **Push after a successful merge to keep remotes aligned**
- If you only merged upstream RC changes, push **origin only** to sync your fork:
```bash
git push origin <branch>
```
- If you have local fixes to publish, push **origin + upstream**:
```bash
git push origin <branch>
git push upstream <branch>:<TARGET_RC>
```
- Always ask the user which push to perform.
- Origin (origin-only sync):
```bash
git push origin <branch>
```
- Upstream RC (publish the same fixes when you have local commits):
```bash
git push upstream <branch>:<TARGET_RC>
```
7. **Re-check sync**
```bash
./check-sync.sh
```
## PR Workflow (Feature Work)
Use this flow only for new feature work on a new branch. Do not push to upstream RC.
1. **Create or switch to a feature branch**
```bash
git checkout -b <branch>
```
2. **Make changes and commit**
```bash
git add -A
git commit -m "type: description"
```
3. **Merge upstream RC before shipping**
```bash
git merge upstream/<TARGET_RC> --no-edit
```
4. **Build and/or test**
```bash
npm run build:packages
npm run build
```
5. **Push to origin**
```bash
git push -u origin <branch>
```
6. **Create or update the PR**
- Use `gh pr create` or the GitHub UI.
7. **Review and follow-up**
- Apply feedback, commit changes, and push again.
- Re-run `./check-sync.sh` if additional upstream sync is needed.
## Conflict Resolution Checklist
1. Identify which changes are from upstream vs. local.
2. Preserve both behaviors where possible; avoid dropping either side.
3. Prefer minimal, safe integrations over refactors.
4. Re-run build commands after resolving conflicts.
5. Re-run `./check-sync.sh` to confirm status.
## Build/Test Matrix
- **Sync Workflow**: `npm run build:packages` and `npm run build`.
- **PR Workflow**: `npm run build:packages` and `npm run build` (plus relevant tests).
## Post-Sync Verification
1. `git status` should be clean.
2. `./check-sync.sh` should show expected alignment.
3. Verify recent commits with:
```bash
git log --oneline -5
```
## check-sync.sh Usage
- Uses dynamic Target RC resolution (see above).
- Override target RC:
```bash
./check-sync.sh --rc <rc-branch>
```
- Optional preview limit:
```bash
./check-sync.sh --preview 10
```
- The script prints sync status for both origin and upstream and previews recent commits
when you are behind.
## Stop Conditions
Stop and ask for guidance if any of the following are true:
- The working tree is dirty and you are about to merge or push.
- `./check-sync.sh` reports **diverged** during PR Workflow, or a merge cannot be completed.
- The script cannot resolve a target RC and requests `--rc`.
- A build fails after sync or conflict resolution.
## AI Agent Guardrails
- Always run `./check-sync.sh` before merges or pushes.
- Always ask for explicit user approval before any push command.
- Do not ask for additional confirmation before a Sync Workflow fetch + merge when the
working tree is clean and the user has already selected the Sync Workflow.
- Choose Sync vs PR workflow based on intent (RC maintenance vs new feature work), not
on the script's workflow hint.
- Only use force push when the user explicitly requests a history rewrite.
- Ask for explicit approval before dependency installs, branch deletion, or destructive operations.
- When resolving merge conflicts, preserve both upstream changes and local intent where possible.
- Do not create or switch to new branches unless the user explicitly requests it.
## AI Agent Decision Guidance
Agents should provide concrete, task-specific suggestions instead of repeatedly asking
open-ended questions. Use the user's stated goal and the `./check-sync.sh` status to
propose a default path plus one or two alternatives, and only ask for confirmation when
an action requires explicit approval.
Default behavior:
- If the intent is RC maintenance, recommend the Sync Workflow and proceed with
safe preparation steps (status checks, previews). If the branch is behind upstream RC,
fetch and merge without additional confirmation when the working tree is clean, then
push to origin to keep the fork aligned. Push upstream only when there are local fixes
to publish.
- If the intent is new feature work, recommend the PR Workflow and proceed with safe
preparation steps (status checks, identifying scope). Ask for approval before merges,
pushes, or dependency installs.
- If `./check-sync.sh` reports **diverged** during Sync Workflow, merge
`upstream/<TARGET_RC>` into the current branch and preserve local commits.
- If `./check-sync.sh` reports **diverged** during PR Workflow, stop and ask for guidance
with a short explanation of the divergence and the minimal options to resolve it.
If the user's intent is RC maintenance, prefer the Sync Workflow regardless of the
script hint. When the intent is new feature work, use the PR Workflow and avoid upstream
RC pushes.
Suggestion format (keep it short):
- **Recommended**: one sentence with the default path and why it fits the task.
- **Alternatives**: one or two options with the tradeoff or prerequisite.
- **Approval points**: mention any upcoming actions that need explicit approval (exclude sync
workflow pushes and merges).
## Failure Modes and How to Avoid Them
Sync Workflow:
- Wrong RC target: verify the auto-detected RC in `./check-sync.sh` output before merging.
- Diverged from upstream RC: stop and resolve manually before any merge or push.
- Dirty working tree: commit or stash before syncing to avoid accidental merges.
- Missing remotes: ensure both `origin` and `upstream` are configured before syncing.
- Build breaks after sync: run `npm run build:packages` and `npm run build` before pushing.
PR Workflow:
- Branch not synced to current RC: re-run `./check-sync.sh` and merge RC before shipping.
- Pushing the wrong branch: confirm `git branch --show-current` before pushing.
- Unreviewed changes: always commit and push to origin before opening or updating a PR.
- Skipped tests/builds: run the build commands before declaring the PR ready.
## Notes
- Avoid merging with uncommitted changes; commit or stash first.
- Prefer merge over rebase for PR branches; rebases rewrite history and often require a force push,
which should only be done with an explicit user request.
- Use clear, conventional commit messages and split unrelated changes into separate commits.

View File

@@ -118,6 +118,7 @@ RUN curl -fsSL https://opencode.ai/install | bash && \
echo "=== Checking OpenCode CLI installation ===" && \
ls -la /home/automaker/.local/bin/ && \
(which opencode && opencode --version) || echo "opencode installed (may need auth setup)"
USER root
# Add PATH to profile so it's available in all interactive shells (for login shells)
@@ -147,6 +148,15 @@ COPY --from=server-builder /app/apps/server/package*.json ./apps/server/
# Copy node_modules (includes symlinks to libs)
COPY --from=server-builder /app/node_modules ./node_modules
# Install Playwright Chromium browser for AI agent verification tests
# This adds ~300MB to the image but enables automated testing mode out of the box
# Using the locally installed playwright ensures we use the pinned version from package-lock.json
USER automaker
RUN ./node_modules/.bin/playwright install chromium && \
echo "=== Playwright Chromium installed ===" && \
ls -la /home/automaker/.cache/ms-playwright/
USER root
# Create data and projects directories
RUN mkdir -p /data /projects && chown automaker:automaker /data /projects
@@ -199,9 +209,10 @@ COPY libs ./libs
COPY apps/ui ./apps/ui
# Build packages in dependency order, then build UI
# VITE_SERVER_URL tells the UI where to find the API server
# Use ARG to allow overriding at build time: --build-arg VITE_SERVER_URL=http://api.example.com
ARG VITE_SERVER_URL=http://localhost:3008
# When VITE_SERVER_URL is empty, the UI uses relative URLs (e.g., /api/...) which nginx proxies
# to the server container. This avoids CORS issues entirely in Docker Compose setups.
# Override at build time if needed: --build-arg VITE_SERVER_URL=http://api.example.com
ARG VITE_SERVER_URL=
ENV VITE_SKIP_ELECTRON=true
ENV VITE_SERVER_URL=${VITE_SERVER_URL}
RUN npm run build:packages && npm run build --workspace=apps/ui

2
OPENCODE_CONFIG_CONTENT Normal file
View File

@@ -0,0 +1,2 @@
{
"$schema": "https://opencode.ai/config.json",}

View File

@@ -14,10 +14,6 @@
**Stop typing code. Start directing AI agents.**
> **[!WARNING]**
>
> **This project is no longer actively maintained.** The codebase is provided as-is. No bug fixes, security updates, or new features are being developed.
<details open>
<summary><h2>Table of Contents</h2></summary>
@@ -367,6 +363,42 @@ services:
The Docker image supports both AMD64 and ARM64 architectures. The GitHub CLI and Claude CLI are automatically downloaded for the correct architecture during build.
##### Playwright for Automated Testing
The Docker image includes **Playwright Chromium pre-installed** for AI agent verification tests. When agents implement features in automated testing mode, they use Playwright to verify the implementation works correctly.
**No additional setup required** - Playwright verification works out of the box.
#### Optional: Persist browsers for manual updates
By default, Playwright Chromium is pre-installed in the Docker image. If you need to manually update browsers or want to persist browser installations across container restarts (not image rebuilds), you can mount a volume.
**Important:** When you first add this volume mount to an existing setup, the empty volume will override the pre-installed browsers. You must re-install them:
```bash
# After adding the volume mount for the first time
docker exec --user automaker -w /app automaker-server npx playwright install chromium
```
Add this to your `docker-compose.override.yml`:
```yaml
services:
server:
volumes:
- playwright-cache:/home/automaker/.cache/ms-playwright
volumes:
playwright-cache:
name: automaker-playwright-cache
```
**Updating browsers manually:**
```bash
docker exec --user automaker -w /app automaker-server npx playwright install chromium
```
### Testing
#### End-to-End Tests (Playwright)

View File

@@ -52,6 +52,12 @@ HOST=0.0.0.0
# Port to run the server on
PORT=3008
# Port to run the server on for testing
TEST_SERVER_PORT=3108
# Port to run the UI on for testing
TEST_PORT=3107
# Data directory for sessions and metadata
DATA_DIR=./data

View File

@@ -0,0 +1,74 @@
import { defineConfig, globalIgnores } from 'eslint/config';
import js from '@eslint/js';
import ts from '@typescript-eslint/eslint-plugin';
import tsParser from '@typescript-eslint/parser';
const eslintConfig = defineConfig([
js.configs.recommended,
{
files: ['**/*.ts'],
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
},
globals: {
// Node.js globals
console: 'readonly',
process: 'readonly',
Buffer: 'readonly',
__dirname: 'readonly',
__filename: 'readonly',
URL: 'readonly',
URLSearchParams: 'readonly',
AbortController: 'readonly',
AbortSignal: 'readonly',
fetch: 'readonly',
Response: 'readonly',
Request: 'readonly',
Headers: 'readonly',
FormData: 'readonly',
RequestInit: 'readonly',
// Timers
setTimeout: 'readonly',
setInterval: 'readonly',
clearTimeout: 'readonly',
clearInterval: 'readonly',
setImmediate: 'readonly',
clearImmediate: 'readonly',
queueMicrotask: 'readonly',
// Node.js types
NodeJS: 'readonly',
},
},
plugins: {
'@typescript-eslint': ts,
},
rules: {
...ts.configs.recommended.rules,
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
ignoreRestSiblings: true,
},
],
'@typescript-eslint/no-explicit-any': 'warn',
// Server code frequently works with terminal output containing ANSI escape codes
'no-control-regex': 'off',
'@typescript-eslint/ban-ts-comment': [
'error',
{
'ts-nocheck': 'allow-with-description',
minimumDescriptionLength: 10,
},
],
},
},
globalIgnores(['dist/**', 'node_modules/**']),
]);
export default eslintConfig;

View File

@@ -1,6 +1,6 @@
{
"name": "@automaker/server",
"version": "0.13.0",
"version": "1.0.0",
"description": "Backend server for Automaker - provides API for both web and Electron modes",
"author": "AutoMaker Team",
"license": "SEE LICENSE IN LICENSE",
@@ -24,7 +24,7 @@
"test:unit": "vitest run tests/unit"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "0.1.76",
"@anthropic-ai/claude-agent-sdk": "0.2.32",
"@automaker/dependency-resolver": "1.0.0",
"@automaker/git-utils": "1.0.0",
"@automaker/model-resolver": "1.0.0",
@@ -32,9 +32,9 @@
"@automaker/prompts": "1.0.0",
"@automaker/types": "1.0.0",
"@automaker/utils": "1.0.0",
"@github/copilot-sdk": "^0.1.16",
"@github/copilot-sdk": "0.1.16",
"@modelcontextprotocol/sdk": "1.25.2",
"@openai/codex-sdk": "^0.77.0",
"@openai/codex-sdk": "^0.98.0",
"cookie-parser": "1.4.7",
"cors": "2.8.5",
"dotenv": "17.2.3",
@@ -45,6 +45,7 @@
"yaml": "2.7.0"
},
"devDependencies": {
"@playwright/test": "1.57.0",
"@types/cookie": "0.6.0",
"@types/cookie-parser": "1.4.10",
"@types/cors": "2.8.19",

View File

@@ -56,7 +56,7 @@ import {
import { createSettingsRoutes } from './routes/settings/index.js';
import { AgentService } from './services/agent-service.js';
import { FeatureLoader } from './services/feature-loader.js';
import { AutoModeService } from './services/auto-mode-service.js';
import { AutoModeServiceCompat } from './services/auto-mode/index.js';
import { getTerminalService } from './services/terminal-service.js';
import { SettingsService } from './services/settings-service.js';
import { createSpecRegenerationRoutes } from './routes/app-spec/index.js';
@@ -66,6 +66,10 @@ import { createCodexRoutes } from './routes/codex/index.js';
import { CodexUsageService } from './services/codex-usage-service.js';
import { CodexAppServerService } from './services/codex-app-server-service.js';
import { CodexModelCacheService } from './services/codex-model-cache-service.js';
import { createZaiRoutes } from './routes/zai/index.js';
import { ZaiUsageService } from './services/zai-usage-service.js';
import { createGeminiRoutes } from './routes/gemini/index.js';
import { GeminiUsageService } from './services/gemini-usage-service.js';
import { createGitHubRoutes } from './routes/github/index.js';
import { createContextRoutes } from './routes/context/index.js';
import { createBacklogPlanRoutes } from './routes/backlog-plan/index.js';
@@ -121,21 +125,57 @@ const BOX_CONTENT_WIDTH = 67;
// The Claude Agent SDK can use either ANTHROPIC_API_KEY or Claude Code CLI authentication
(async () => {
const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY;
const hasEnvOAuthToken = !!process.env.CLAUDE_CODE_OAUTH_TOKEN;
logger.debug('[CREDENTIAL_CHECK] Starting credential detection...');
logger.debug('[CREDENTIAL_CHECK] Environment variables:', {
hasAnthropicKey,
hasEnvOAuthToken,
});
if (hasAnthropicKey) {
logger.info('✓ ANTHROPIC_API_KEY detected');
return;
}
if (hasEnvOAuthToken) {
logger.info('✓ CLAUDE_CODE_OAUTH_TOKEN detected');
return;
}
// Check for Claude Code CLI authentication
// Store indicators outside the try block so we can use them in the warning message
let cliAuthIndicators: Awaited<ReturnType<typeof getClaudeAuthIndicators>> | null = null;
try {
const indicators = await getClaudeAuthIndicators();
cliAuthIndicators = await getClaudeAuthIndicators();
const indicators = cliAuthIndicators;
// Log detailed credential detection results
const { checks, ...indicatorSummary } = indicators;
logger.debug('[CREDENTIAL_CHECK] Claude CLI auth indicators:', indicatorSummary);
logger.debug('[CREDENTIAL_CHECK] File check details:', checks);
const hasCliAuth =
indicators.hasStatsCacheWithActivity ||
(indicators.hasSettingsFile && indicators.hasProjectsSessions) ||
(indicators.hasCredentialsFile &&
(indicators.credentials?.hasOAuthToken || indicators.credentials?.hasApiKey));
logger.debug('[CREDENTIAL_CHECK] Auth determination:', {
hasCliAuth,
reason: hasCliAuth
? indicators.hasStatsCacheWithActivity
? 'stats cache with activity'
: indicators.hasSettingsFile && indicators.hasProjectsSessions
? 'settings file + project sessions'
: indicators.credentials?.hasOAuthToken
? 'credentials file with OAuth token'
: 'credentials file with API key'
: 'no valid credentials found',
});
if (hasCliAuth) {
logger.info('✓ Claude Code CLI authentication detected');
return;
@@ -145,7 +185,7 @@ const BOX_CONTENT_WIDTH = 67;
logger.warn('Error checking for Claude Code CLI authentication:', error);
}
// No authentication found - show warning
// No authentication found - show warning with paths that were checked
const wHeader = '⚠️ WARNING: No Claude authentication configured'.padEnd(BOX_CONTENT_WIDTH);
const w1 = 'The Claude Agent SDK requires authentication to function.'.padEnd(BOX_CONTENT_WIDTH);
const w2 = 'Options:'.padEnd(BOX_CONTENT_WIDTH);
@@ -158,6 +198,33 @@ const BOX_CONTENT_WIDTH = 67;
BOX_CONTENT_WIDTH
);
// Build paths checked summary from the indicators (if available)
let pathsCheckedInfo = '';
if (cliAuthIndicators) {
const pathsChecked: string[] = [];
// Collect paths that were checked (paths are always populated strings)
pathsChecked.push(`Settings: ${cliAuthIndicators.checks.settingsFile.path}`);
pathsChecked.push(`Stats cache: ${cliAuthIndicators.checks.statsCache.path}`);
pathsChecked.push(`Projects dir: ${cliAuthIndicators.checks.projectsDir.path}`);
for (const credFile of cliAuthIndicators.checks.credentialFiles) {
pathsChecked.push(`Credentials: ${credFile.path}`);
}
if (pathsChecked.length > 0) {
pathsCheckedInfo = `
║ ║
${'Paths checked:'.padEnd(BOX_CONTENT_WIDTH)}
${pathsChecked
.map((p) => {
const maxLen = BOX_CONTENT_WIDTH - 4;
const display = p.length > maxLen ? '...' + p.slice(-(maxLen - 3)) : p;
return `${display.padEnd(maxLen)}`;
})
.join('\n')}`;
}
}
logger.warn(`
╔═════════════════════════════════════════════════════════════════════╗
${wHeader}
@@ -169,7 +236,7 @@ const BOX_CONTENT_WIDTH = 67;
${w3}
${w4}
${w5}
${w6}
${w6}${pathsCheckedInfo}
║ ║
╚═════════════════════════════════════════════════════════════════════╝
`);
@@ -194,12 +261,35 @@ morgan.token('status-colored', (_req, res) => {
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',
skip: (req) =>
!requestLoggingEnabled ||
req.url === '/api/health' ||
req.url === '/api/auto-mode/context-exists',
})
);
// CORS configuration
// When using credentials (cookies), origin cannot be '*'
// We dynamically allow the requesting origin for local development
// Check if origin is a local/private network address
function isLocalOrigin(origin: string): boolean {
try {
const url = new URL(origin);
const hostname = url.hostname;
return (
hostname === 'localhost' ||
hostname === '127.0.0.1' ||
hostname === '[::1]' ||
hostname === '0.0.0.0' ||
hostname.startsWith('192.168.') ||
hostname.startsWith('10.') ||
/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(hostname)
);
} catch {
return false;
}
}
app.use(
cors({
origin: (origin, callback) => {
@@ -210,35 +300,25 @@ app.use(
}
// If CORS_ORIGIN is set, use it (can be comma-separated list)
const allowedOrigins = process.env.CORS_ORIGIN?.split(',').map((o) => o.trim());
if (allowedOrigins && allowedOrigins.length > 0 && allowedOrigins[0] !== '*') {
if (allowedOrigins.includes(origin)) {
callback(null, origin);
} else {
callback(new Error('Not allowed by CORS'));
const allowedOrigins = process.env.CORS_ORIGIN?.split(',')
.map((o) => o.trim())
.filter(Boolean);
if (allowedOrigins && allowedOrigins.length > 0) {
if (allowedOrigins.includes('*')) {
callback(null, true);
return;
}
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.')
) {
if (allowedOrigins.includes(origin)) {
callback(null, origin);
return;
}
} catch (err) {
// Ignore URL parsing errors
// Fall through to local network check below
}
// Allow all localhost/loopback/private network origins (any port)
if (isLocalOrigin(origin)) {
callback(null, origin);
return;
}
// Reject other origins by default for security
@@ -258,17 +338,23 @@ const events: EventEmitter = createEventEmitter();
const settingsService = new SettingsService(DATA_DIR);
const agentService = new AgentService(DATA_DIR, events, settingsService);
const featureLoader = new FeatureLoader();
const autoModeService = new AutoModeService(events, settingsService);
// Auto-mode services: compatibility layer provides old interface while using new architecture
const autoModeService = new AutoModeServiceCompat(events, settingsService, featureLoader);
const claudeUsageService = new ClaudeUsageService();
const codexAppServerService = new CodexAppServerService();
const codexModelCacheService = new CodexModelCacheService(DATA_DIR, codexAppServerService);
const codexUsageService = new CodexUsageService(codexAppServerService);
const zaiUsageService = new ZaiUsageService();
const geminiUsageService = new GeminiUsageService();
const mcpTestService = new MCPTestService(settingsService);
const ideationService = new IdeationService(events, settingsService, featureLoader);
// Initialize DevServerService with event emitter for real-time log streaming
const devServerService = getDevServerService();
devServerService.setEventEmitter(events);
devServerService.initialize(DATA_DIR, events).catch((err) => {
logger.error('Failed to initialize DevServerService:', err);
});
// Initialize Notification Service with event emitter for real-time updates
const notificationService = getNotificationService();
@@ -303,24 +389,74 @@ eventHookService.initialize(events, settingsService, eventHistoryService, featur
logger.warn('Failed to check for legacy settings migration:', err);
}
// Apply logging settings from saved settings
// Fetch global settings once and reuse for logging config and feature reconciliation
let globalSettings: Awaited<ReturnType<typeof settingsService.getGlobalSettings>> | null = null;
try {
const settings = await settingsService.getGlobalSettings();
if (settings.serverLogLevel && LOG_LEVEL_MAP[settings.serverLogLevel] !== undefined) {
setLogLevel(LOG_LEVEL_MAP[settings.serverLogLevel]);
logger.info(`Server log level set to: ${settings.serverLogLevel}`);
globalSettings = await settingsService.getGlobalSettings();
} catch {
logger.warn('Failed to load global settings, using defaults');
}
// Apply logging settings from saved settings
if (globalSettings) {
try {
if (
globalSettings.serverLogLevel &&
LOG_LEVEL_MAP[globalSettings.serverLogLevel] !== undefined
) {
setLogLevel(LOG_LEVEL_MAP[globalSettings.serverLogLevel]);
logger.info(`Server log level set to: ${globalSettings.serverLogLevel}`);
}
// Apply request logging setting (default true if not set)
const enableRequestLog = globalSettings.enableRequestLogging ?? true;
setRequestLoggingEnabled(enableRequestLog);
logger.info(`HTTP request logging: ${enableRequestLog ? 'enabled' : 'disabled'}`);
} catch {
logger.warn('Failed to apply logging settings, using defaults');
}
// 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');
// Reconcile feature states on startup
// After any type of restart (clean, forced, crash), features may be stuck in
// transient states (in_progress, interrupted, pipeline_*) that don't match reality.
// Reconcile them back to resting states before the UI is served.
if (globalSettings) {
try {
if (globalSettings.projects && globalSettings.projects.length > 0) {
let totalReconciled = 0;
for (const project of globalSettings.projects) {
const count = await autoModeService.reconcileFeatureStates(project.path);
totalReconciled += count;
}
if (totalReconciled > 0) {
logger.info(
`[STARTUP] Reconciled ${totalReconciled} feature(s) across ${globalSettings.projects.length} project(s)`
);
} else {
logger.info('[STARTUP] Feature state reconciliation complete - no stale states found');
}
// Resume interrupted features in the background for all projects.
// This handles features stuck in transient states (in_progress, pipeline_*)
// or explicitly marked as interrupted. Running in background so it doesn't block startup.
for (const project of globalSettings.projects) {
autoModeService.resumeInterruptedFeatures(project.path).catch((err) => {
logger.warn(
`[STARTUP] Failed to resume interrupted features for ${project.path}:`,
err
);
});
}
logger.info('[STARTUP] Initiated background resume of interrupted features');
}
} catch (err) {
logger.warn('[STARTUP] Failed to reconcile feature states:', err);
}
}
// Bootstrap Codex model cache in background (don't block server startup)
void codexModelCacheService.getModels().catch((err) => {
logger.error('Failed to bootstrap Codex model cache:', err);
@@ -360,7 +496,7 @@ app.use(
);
app.use('/api/auto-mode', createAutoModeRoutes(autoModeService));
app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService));
app.use('/api/worktree', createWorktreeRoutes(events, settingsService));
app.use('/api/worktree', createWorktreeRoutes(events, settingsService, featureLoader));
app.use('/api/git', createGitRoutes());
app.use('/api/models', createModelsRoutes());
app.use('/api/spec-regeneration', createSpecRegenerationRoutes(events, settingsService));
@@ -371,6 +507,8 @@ app.use('/api/terminal', createTerminalRoutes());
app.use('/api/settings', createSettingsRoutes(settingsService));
app.use('/api/claude', createClaudeRoutes(claudeUsageService));
app.use('/api/codex', createCodexRoutes(codexUsageService, codexModelCacheService));
app.use('/api/zai', createZaiRoutes(zaiUsageService, settingsService));
app.use('/api/gemini', createGeminiRoutes(geminiUsageService, events));
app.use('/api/github', createGitHubRoutes(events, settingsService));
app.use('/api/context', createContextRoutes(settingsService));
app.use('/api/backlog-plan', createBacklogPlanRoutes(events, settingsService));
@@ -460,24 +598,23 @@ wss.on('connection', (ws: WebSocket) => {
// Subscribe to all events and forward to this client
const unsubscribe = events.subscribe((type, payload) => {
logger.info('Event received:', {
// Use debug level for high-frequency events to avoid log spam
// that causes progressive memory growth and server slowdown
const isHighFrequency =
type === 'dev-server:output' || type === 'test-runner:output' || type === 'feature:progress';
const log = isHighFrequency ? logger.debug.bind(logger) : logger.info.bind(logger);
log('Event received:', {
type,
hasPayload: !!payload,
payloadKeys: payload ? Object.keys(payload) : [],
wsReadyState: ws.readyState,
wsOpen: ws.readyState === WebSocket.OPEN,
});
if (ws.readyState === WebSocket.OPEN) {
const message = JSON.stringify({ type, payload });
logger.info('Sending event to client:', {
type,
messageLength: message.length,
sessionId: (payload as any)?.sessionId,
});
ws.send(message);
} else {
logger.info('WARNING: Cannot send event, WebSocket not open. ReadyState:', ws.readyState);
logger.warn('Cannot send event, WebSocket not open. ReadyState:', ws.readyState);
}
});
@@ -539,8 +676,15 @@ terminalWss.on('connection', (ws: WebSocket, req: import('http').IncomingMessage
// Check if session exists
const session = terminalService.getSession(sessionId);
if (!session) {
logger.info(`Session ${sessionId} not found`);
ws.close(4004, 'Session not found');
logger.warn(
`Terminal session ${sessionId} not found. ` +
`The session may have exited, been deleted, or was never created. ` +
`Active terminal sessions: ${terminalService.getSessionCount()}`
);
ws.close(
4004,
'Session not found. The terminal session may have expired or been closed. Please create a new terminal.'
);
return;
}

View File

@@ -8,9 +8,6 @@ import { spawn, execSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { createLogger } from '@automaker/utils';
const logger = createLogger('CliDetection');
export interface CliInfo {
name: string;
@@ -86,7 +83,7 @@ export async function detectCli(
options: CliDetectionOptions = {}
): Promise<CliDetectionResult> {
const config = CLI_CONFIGS[provider];
const { timeout = 5000, includeWsl = false, wslDistribution } = options;
const { timeout = 5000 } = options;
const issues: string[] = [];
const cliInfo: CliInfo = {

View File

@@ -40,7 +40,7 @@ export interface ErrorClassification {
suggestedAction?: string;
retryable: boolean;
provider?: string;
context?: Record<string, any>;
context?: Record<string, unknown>;
}
export interface ErrorPattern {
@@ -180,7 +180,7 @@ const ERROR_PATTERNS: ErrorPattern[] = [
export function classifyError(
error: unknown,
provider?: string,
context?: Record<string, any>
context?: Record<string, unknown>
): ErrorClassification {
const errorText = getErrorText(error);
@@ -281,18 +281,19 @@ function getErrorText(error: unknown): string {
if (typeof error === 'object' && error !== null) {
// Handle structured error objects
const errorObj = error as any;
const errorObj = error as Record<string, unknown>;
if (errorObj.message) {
if (typeof errorObj.message === 'string') {
return errorObj.message;
}
if (errorObj.error?.message) {
return errorObj.error.message;
const nestedError = errorObj.error;
if (typeof nestedError === 'object' && nestedError !== null && 'message' in nestedError) {
return String((nestedError as Record<string, unknown>).message);
}
if (errorObj.error) {
return typeof errorObj.error === 'string' ? errorObj.error : JSON.stringify(errorObj.error);
if (nestedError) {
return typeof nestedError === 'string' ? nestedError : JSON.stringify(nestedError);
}
return JSON.stringify(error);
@@ -307,7 +308,7 @@ function getErrorText(error: unknown): string {
export function createErrorResponse(
error: unknown,
provider?: string,
context?: Record<string, any>
context?: Record<string, unknown>
): {
success: false;
error: string;
@@ -335,7 +336,7 @@ export function logError(
error: unknown,
provider?: string,
operation?: string,
additionalContext?: Record<string, any>
additionalContext?: Record<string, unknown>
): void {
const classification = classifyError(error, provider, {
operation,

View File

@@ -0,0 +1,37 @@
/**
* Shared execution utilities
*
* Common helpers for spawning child processes with the correct environment.
* Used by both route handlers and service layers.
*/
import { createLogger } from '@automaker/utils';
const logger = createLogger('ExecUtils');
// Extended PATH to include common tool installation locations
export const extendedPath = [
process.env.PATH,
'/opt/homebrew/bin',
'/usr/local/bin',
'/home/linuxbrew/.linuxbrew/bin',
`${process.env.HOME}/.local/bin`,
]
.filter(Boolean)
.join(':');
export const execEnv = {
...process.env,
PATH: extendedPath,
};
export function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
export function logError(error: unknown, context: string): void {
logger.error(`${context}:`, error);
}

View File

@@ -0,0 +1,62 @@
export interface CommitFields {
hash: string;
shortHash: string;
author: string;
authorEmail: string;
date: string;
subject: string;
body: string;
}
export function parseGitLogOutput(output: string): CommitFields[] {
const commits: CommitFields[] = [];
// Split by NUL character to separate commits
const commitBlocks = output.split('\0').filter((block) => block.trim());
for (const block of commitBlocks) {
const allLines = block.split('\n');
// Skip leading empty lines that may appear at block boundaries
let startIndex = 0;
while (startIndex < allLines.length && allLines[startIndex].trim() === '') {
startIndex++;
}
const fields = allLines.slice(startIndex);
// Validate we have all expected fields (at least hash, shortHash, author, authorEmail, date, subject)
if (fields.length < 6) {
continue; // Skip malformed blocks
}
const commit: CommitFields = {
hash: fields[0].trim(),
shortHash: fields[1].trim(),
author: fields[2].trim(),
authorEmail: fields[3].trim(),
date: fields[4].trim(),
subject: fields[5].trim(),
body: fields.slice(6).join('\n').trim(),
};
commits.push(commit);
}
return commits;
}
/**
* Creates a commit object from parsed fields, matching the expected API response format
*/
export function createCommitFromFields(fields: CommitFields, files?: string[]) {
return {
hash: fields.hash,
shortHash: fields.shortHash,
author: fields.author,
authorEmail: fields.authorEmail,
date: fields.date,
subject: fields.subject,
body: fields.body,
files: files || [],
};
}

236
apps/server/src/lib/git.ts Normal file
View File

@@ -0,0 +1,236 @@
/**
* Shared git command execution utilities.
*
* This module provides the canonical `execGitCommand` helper and common
* git utilities used across services and routes. All consumers should
* import from here rather than defining their own copy.
*/
import fs from 'fs/promises';
import path from 'path';
import { spawnProcess } from '@automaker/platform';
import { createLogger } from '@automaker/utils';
const logger = createLogger('GitLib');
// Extended PATH so git is found when the process does not inherit a full shell PATH
// (e.g. Electron, some CI, or IDE-launched processes).
const pathSeparator = process.platform === 'win32' ? ';' : ':';
const extraPaths: string[] =
process.platform === 'win32'
? ([
process.env.LOCALAPPDATA && `${process.env.LOCALAPPDATA}\\Programs\\Git\\cmd`,
process.env.PROGRAMFILES && `${process.env.PROGRAMFILES}\\Git\\cmd`,
process.env['ProgramFiles(x86)'] && `${process.env['ProgramFiles(x86)']}\\Git\\cmd`,
].filter(Boolean) as string[])
: [
'/opt/homebrew/bin',
'/usr/local/bin',
'/usr/bin',
'/home/linuxbrew/.linuxbrew/bin',
process.env.HOME ? `${process.env.HOME}/.local/bin` : '',
].filter(Boolean);
const extendedPath = [process.env.PATH, ...extraPaths].filter(Boolean).join(pathSeparator);
const gitEnv = { ...process.env, PATH: extendedPath };
// ============================================================================
// Secure Command Execution
// ============================================================================
/**
* Execute git command with array arguments to prevent command injection.
* Uses spawnProcess from @automaker/platform for secure, cross-platform execution.
*
* @param args - Array of git command arguments (e.g., ['worktree', 'add', path])
* @param cwd - Working directory to execute the command in
* @param env - Optional additional environment variables to pass to the git process.
* These are merged on top of the current process environment. Pass
* `{ LC_ALL: 'C' }` to force git to emit English output regardless of the
* system locale so that text-based output parsing remains reliable.
* @param abortController - Optional AbortController to cancel the git process.
* When the controller is aborted the underlying process is sent SIGTERM and
* the returned promise rejects with an Error whose message is 'Process aborted'.
* @returns Promise resolving to stdout output
* @throws Error with stderr/stdout message if command fails. The thrown error
* also has `stdout` and `stderr` string properties for structured access.
*
* @example
* ```typescript
* // Safe: no injection possible
* await execGitCommand(['branch', '-D', branchName], projectPath);
*
* // Force English output for reliable text parsing:
* await execGitCommand(['rebase', '--', 'main'], worktreePath, { LC_ALL: 'C' });
*
* // With a process-level timeout:
* const controller = new AbortController();
* const timerId = setTimeout(() => controller.abort(), 30_000);
* try {
* await execGitCommand(['fetch', '--all', '--quiet'], cwd, undefined, controller);
* } finally {
* clearTimeout(timerId);
* }
*
* // Instead of unsafe:
* // await execAsync(`git branch -D ${branchName}`, { cwd });
* ```
*/
export async function execGitCommand(
args: string[],
cwd: string,
env?: Record<string, string>,
abortController?: AbortController
): Promise<string> {
const result = await spawnProcess({
command: 'git',
args,
cwd,
env:
env !== undefined
? {
...gitEnv,
...env,
PATH: [gitEnv.PATH, env.PATH].filter(Boolean).join(pathSeparator),
}
: gitEnv,
...(abortController !== undefined ? { abortController } : {}),
});
// spawnProcess returns { stdout, stderr, exitCode }
if (result.exitCode === 0) {
return result.stdout;
} else {
const errorMessage =
result.stderr || result.stdout || `Git command failed with code ${result.exitCode}`;
throw Object.assign(new Error(errorMessage), {
stdout: result.stdout,
stderr: result.stderr,
});
}
}
// ============================================================================
// Common Git Utilities
// ============================================================================
/**
* Get the current branch name for the given worktree.
*
* This is the canonical implementation shared across services. Services
* should import this rather than duplicating the logic locally.
*
* @param worktreePath - Path to the git worktree
* @returns The current branch name (trimmed)
*/
export async function getCurrentBranch(worktreePath: string): Promise<string> {
const branchOutput = await execGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], worktreePath);
return branchOutput.trim();
}
// ============================================================================
// Index Lock Recovery
// ============================================================================
/**
* Check whether an error message indicates a stale git index lock file.
*
* Git operations that write to the index (e.g. `git stash push`) will fail
* with "could not write index" or "Unable to create ... .lock" when a
* `.git/index.lock` file exists from a previously interrupted operation.
*
* @param errorMessage - The error string from a failed git command
* @returns true if the error looks like a stale index lock issue
*/
export function isIndexLockError(errorMessage: string): boolean {
const lower = errorMessage.toLowerCase();
return (
lower.includes('could not write index') ||
(lower.includes('unable to create') && lower.includes('index.lock')) ||
lower.includes('index.lock')
);
}
/**
* Attempt to remove a stale `.git/index.lock` file for the given worktree.
*
* Uses `git rev-parse --git-dir` to locate the correct `.git` directory,
* which works for both regular repositories and linked worktrees.
*
* @param worktreePath - Path to the git worktree (or main repo)
* @returns true if a lock file was found and removed, false otherwise
*/
export async function removeStaleIndexLock(worktreePath: string): Promise<boolean> {
try {
// Resolve the .git directory (handles worktrees correctly)
const gitDirRaw = await execGitCommand(['rev-parse', '--git-dir'], worktreePath);
const gitDir = path.resolve(worktreePath, gitDirRaw.trim());
const lockFilePath = path.join(gitDir, 'index.lock');
// Check if the lock file exists
try {
await fs.access(lockFilePath);
} catch {
// Lock file does not exist — nothing to remove
return false;
}
// Remove the stale lock file
await fs.unlink(lockFilePath);
logger.info('Removed stale index.lock file', { worktreePath, lockFilePath });
return true;
} catch (err) {
logger.warn('Failed to remove stale index.lock file', {
worktreePath,
error: err instanceof Error ? err.message : String(err),
});
return false;
}
}
/**
* Execute a git command with automatic retry when a stale index.lock is detected.
*
* If the command fails with an error indicating a locked index file, this
* helper will attempt to remove the stale `.git/index.lock` and retry the
* command exactly once.
*
* This is particularly useful for `git stash push` which writes to the
* index and commonly fails when a previous git operation was interrupted.
*
* @param args - Array of git command arguments
* @param cwd - Working directory to execute the command in
* @param env - Optional additional environment variables
* @returns Promise resolving to stdout output
* @throws The original error if retry also fails, or a non-lock error
*/
export async function execGitCommandWithLockRetry(
args: string[],
cwd: string,
env?: Record<string, string>
): Promise<string> {
try {
return await execGitCommand(args, cwd, env);
} catch (error: unknown) {
const err = error as { message?: string; stderr?: string };
const errorMessage = err.stderr || err.message || '';
if (!isIndexLockError(errorMessage)) {
throw error;
}
logger.info('Git command failed due to index lock, attempting cleanup and retry', {
cwd,
args: args.join(' '),
});
const removed = await removeStaleIndexLock(cwd);
if (!removed) {
// Could not remove the lock file — re-throw the original error
throw error;
}
// Retry the command once after removing the lock file
return await execGitCommand(args, cwd, env);
}
}

View File

@@ -12,11 +12,18 @@ export interface PermissionCheckResult {
reason?: string;
}
/** Minimal shape of a Cursor tool call used for permission checking */
interface CursorToolCall {
shellToolCall?: { args?: { command: string } };
readToolCall?: { args?: { path: string } };
writeToolCall?: { args?: { path: string } };
}
/**
* Check if a tool call is allowed based on permissions
*/
export function checkToolCallPermission(
toolCall: any,
toolCall: CursorToolCall,
permissions: CursorCliConfigFile | null
): PermissionCheckResult {
if (!permissions || !permissions.permissions) {
@@ -152,7 +159,11 @@ function matchesRule(toolName: string, rule: string): boolean {
/**
* Log permission violations
*/
export function logPermissionViolation(toolCall: any, reason: string, sessionId?: string): void {
export function logPermissionViolation(
toolCall: CursorToolCall,
reason: string,
sessionId?: string
): void {
const sessionIdStr = sessionId ? ` [${sessionId}]` : '';
if (toolCall.shellToolCall?.args?.command) {

View File

@@ -133,12 +133,16 @@ export const TOOL_PRESETS = {
'Read',
'Write',
'Edit',
'MultiEdit',
'Glob',
'Grep',
'LS',
'Bash',
'WebSearch',
'WebFetch',
'TodoWrite',
'Task',
'Skill',
] as const,
/** Tools for chat/interactive mode */
@@ -146,12 +150,16 @@ export const TOOL_PRESETS = {
'Read',
'Write',
'Edit',
'MultiEdit',
'Glob',
'Grep',
'LS',
'Bash',
'WebSearch',
'WebFetch',
'TodoWrite',
'Task',
'Skill',
] as const,
} as const;
@@ -253,11 +261,27 @@ function buildMcpOptions(config: CreateSdkOptionsConfig): McpOptions {
/**
* Build thinking options for SDK configuration.
* Converts ThinkingLevel to maxThinkingTokens for the Claude SDK.
* For adaptive thinking (Opus 4.6), omits maxThinkingTokens to let the model
* decide its own reasoning depth.
*
* @param thinkingLevel - The thinking level to convert
* @returns Object with maxThinkingTokens if thinking is enabled
* @returns Object with maxThinkingTokens if thinking is enabled with a budget
*/
function buildThinkingOptions(thinkingLevel?: ThinkingLevel): Partial<Options> {
if (!thinkingLevel || thinkingLevel === 'none') {
return {};
}
// Adaptive thinking (Opus 4.6): don't set maxThinkingTokens
// The model will use adaptive thinking by default
if (thinkingLevel === 'adaptive') {
logger.debug(
`buildThinkingOptions: thinkingLevel="adaptive" -> no maxThinkingTokens (model decides)`
);
return {};
}
// Manual budget-based thinking for Haiku/Sonnet
const maxThinkingTokens = getThinkingTokenBudget(thinkingLevel);
logger.debug(
`buildThinkingOptions: thinkingLevel="${thinkingLevel}" -> maxThinkingTokens=${maxThinkingTokens}`
@@ -266,11 +290,15 @@ function buildThinkingOptions(thinkingLevel?: ThinkingLevel): Partial<Options> {
}
/**
* Build system prompt configuration based on autoLoadClaudeMd setting.
* When autoLoadClaudeMd is true:
* - Uses preset mode with 'claude_code' to enable CLAUDE.md auto-loading
* - If there's a custom systemPrompt, appends it to the preset
* - Sets settingSources to ['project'] for SDK to load CLAUDE.md files
* Build system prompt and settingSources based on two independent settings:
* - useClaudeCodeSystemPrompt: controls whether to use the 'claude_code' preset as the base prompt
* - autoLoadClaudeMd: controls whether to add settingSources for SDK to load CLAUDE.md files
*
* These combine independently (4 possible states):
* 1. Both ON: preset + settingSources (full Claude Code experience)
* 2. useClaudeCodeSystemPrompt ON, autoLoadClaudeMd OFF: preset only (no CLAUDE.md auto-loading)
* 3. useClaudeCodeSystemPrompt OFF, autoLoadClaudeMd ON: plain string + settingSources
* 4. Both OFF: plain string only
*
* @param config - The SDK options config
* @returns Object with systemPrompt and settingSources for SDK options
@@ -279,27 +307,34 @@ function buildClaudeMdOptions(config: CreateSdkOptionsConfig): {
systemPrompt?: string | SystemPromptConfig;
settingSources?: Array<'user' | 'project' | 'local'>;
} {
if (!config.autoLoadClaudeMd) {
// Standard mode - just pass through the system prompt as-is
return config.systemPrompt ? { systemPrompt: config.systemPrompt } : {};
}
// Auto-load CLAUDE.md mode - use preset with settingSources
const result: {
systemPrompt: SystemPromptConfig;
settingSources: Array<'user' | 'project' | 'local'>;
} = {
systemPrompt: {
systemPrompt?: string | SystemPromptConfig;
settingSources?: Array<'user' | 'project' | 'local'>;
} = {};
// Determine system prompt format based on useClaudeCodeSystemPrompt
if (config.useClaudeCodeSystemPrompt) {
// Use Claude Code's built-in system prompt as the base
const presetConfig: SystemPromptConfig = {
type: 'preset',
preset: 'claude_code',
},
// Load both user (~/.claude/CLAUDE.md) and project (.claude/CLAUDE.md) settings
settingSources: ['user', 'project'],
};
};
// If there's a custom system prompt, append it to the preset
if (config.systemPrompt) {
presetConfig.append = config.systemPrompt;
}
result.systemPrompt = presetConfig;
} else {
// Standard mode - just pass through the system prompt as-is
if (config.systemPrompt) {
result.systemPrompt = config.systemPrompt;
}
}
// If there's a custom system prompt, append it to the preset
if (config.systemPrompt) {
result.systemPrompt.append = config.systemPrompt;
// Determine settingSources based on autoLoadClaudeMd
if (config.autoLoadClaudeMd) {
// Load both user (~/.claude/CLAUDE.md) and project (.claude/CLAUDE.md) settings
result.settingSources = ['user', 'project'];
}
return result;
@@ -307,12 +342,14 @@ function buildClaudeMdOptions(config: CreateSdkOptionsConfig): {
/**
* System prompt configuration for SDK options
* When using preset mode with claude_code, CLAUDE.md files are automatically loaded
* The 'claude_code' preset provides the system prompt only — it does NOT auto-load
* CLAUDE.md files. CLAUDE.md auto-loading is controlled independently by
* settingSources (set via autoLoadClaudeMd). These two settings are orthogonal.
*/
export interface SystemPromptConfig {
/** Use preset mode with claude_code to enable CLAUDE.md auto-loading */
/** Use preset mode to select the base system prompt */
type: 'preset';
/** The preset to use - 'claude_code' enables CLAUDE.md loading */
/** The preset to use - 'claude_code' uses the Claude Code system prompt */
preset: 'claude_code';
/** Optional additional prompt to append to the preset */
append?: string;
@@ -346,11 +383,19 @@ export interface CreateSdkOptionsConfig {
/** Enable auto-loading of CLAUDE.md files via SDK's settingSources */
autoLoadClaudeMd?: boolean;
/** Use Claude Code's built-in system prompt (claude_code preset) as the base prompt */
useClaudeCodeSystemPrompt?: boolean;
/** MCP servers to make available to the agent */
mcpServers?: Record<string, McpServerConfig>;
/** Extended thinking level for Claude models */
thinkingLevel?: ThinkingLevel;
/** Optional user-configured max turns override (from settings).
* When provided, overrides the preset MAX_TURNS for the use case.
* Range: 1-2000. */
maxTurns?: number;
}
// Re-export MCP types from @automaker/types for convenience
@@ -387,7 +432,7 @@ export function createSpecGenerationOptions(config: CreateSdkOptionsConfig): Opt
// See: https://github.com/AutoMaker-Org/automaker/issues/149
permissionMode: 'default',
model: getModelForUseCase('spec', config.model),
maxTurns: MAX_TURNS.maximum,
maxTurns: config.maxTurns ?? MAX_TURNS.maximum,
cwd: config.cwd,
allowedTools: [...TOOL_PRESETS.specGeneration],
...claudeMdOptions,
@@ -421,7 +466,7 @@ export function createFeatureGenerationOptions(config: CreateSdkOptionsConfig):
// Override permissionMode - feature generation only needs read-only tools
permissionMode: 'default',
model: getModelForUseCase('features', config.model),
maxTurns: MAX_TURNS.quick,
maxTurns: config.maxTurns ?? MAX_TURNS.quick,
cwd: config.cwd,
allowedTools: [...TOOL_PRESETS.readOnly],
...claudeMdOptions,
@@ -452,7 +497,7 @@ export function createSuggestionsOptions(config: CreateSdkOptionsConfig): Option
return {
...getBaseOptions(),
model: getModelForUseCase('suggestions', config.model),
maxTurns: MAX_TURNS.extended,
maxTurns: config.maxTurns ?? MAX_TURNS.extended,
cwd: config.cwd,
allowedTools: [...TOOL_PRESETS.readOnly],
...claudeMdOptions,
@@ -490,7 +535,7 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options {
return {
...getBaseOptions(),
model: getModelForUseCase('chat', effectiveModel),
maxTurns: MAX_TURNS.standard,
maxTurns: config.maxTurns ?? MAX_TURNS.standard,
cwd: config.cwd,
allowedTools: [...TOOL_PRESETS.chat],
...claudeMdOptions,
@@ -525,7 +570,7 @@ export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options {
return {
...getBaseOptions(),
model: getModelForUseCase('auto', config.model),
maxTurns: MAX_TURNS.maximum,
maxTurns: config.maxTurns ?? MAX_TURNS.maximum,
cwd: config.cwd,
allowedTools: [...TOOL_PRESETS.fullAccess],
...claudeMdOptions,

View File

@@ -33,9 +33,16 @@ import {
const logger = createLogger('SettingsHelper');
/** Default number of agent turns used when no value is configured. */
export const DEFAULT_MAX_TURNS = 10000;
/** Upper bound for the max-turns clamp; values above this are capped here. */
export const MAX_ALLOWED_TURNS = 10000;
/**
* Get the autoLoadClaudeMd setting, with project settings taking precedence over global.
* Returns false if settings service is not available.
* Falls back to global settings and defaults to true when unset.
* Returns true if settings service is not available.
*
* @param projectPath - Path to the project
* @param settingsService - Optional settings service instance
@@ -48,8 +55,8 @@ export async function getAutoLoadClaudeMdSetting(
logPrefix = '[SettingsHelper]'
): Promise<boolean> {
if (!settingsService) {
logger.info(`${logPrefix} SettingsService not available, autoLoadClaudeMd disabled`);
return false;
logger.info(`${logPrefix} SettingsService not available, autoLoadClaudeMd defaulting to true`);
return true;
}
try {
@@ -64,7 +71,7 @@ export async function getAutoLoadClaudeMdSetting(
// Fall back to global settings
const globalSettings = await settingsService.getGlobalSettings();
const result = globalSettings.autoLoadClaudeMd ?? false;
const result = globalSettings.autoLoadClaudeMd ?? true;
logger.info(`${logPrefix} autoLoadClaudeMd from global settings: ${result}`);
return result;
} catch (error) {
@@ -73,6 +80,84 @@ export async function getAutoLoadClaudeMdSetting(
}
}
/**
* Get the useClaudeCodeSystemPrompt setting, with project settings taking precedence over global.
* Falls back to global settings and defaults to true when unset.
* Returns true if settings service is not available.
*
* @param projectPath - Path to the project
* @param settingsService - Optional settings service instance
* @param logPrefix - Prefix for log messages (e.g., '[AgentService]')
* @returns Promise resolving to the useClaudeCodeSystemPrompt setting value
*/
export async function getUseClaudeCodeSystemPromptSetting(
projectPath: string,
settingsService?: SettingsService | null,
logPrefix = '[SettingsHelper]'
): Promise<boolean> {
if (!settingsService) {
logger.info(
`${logPrefix} SettingsService not available, useClaudeCodeSystemPrompt defaulting to true`
);
return true;
}
try {
// Check project settings first (takes precedence)
const projectSettings = await settingsService.getProjectSettings(projectPath);
if (projectSettings.useClaudeCodeSystemPrompt !== undefined) {
logger.info(
`${logPrefix} useClaudeCodeSystemPrompt from project settings: ${projectSettings.useClaudeCodeSystemPrompt}`
);
return projectSettings.useClaudeCodeSystemPrompt;
}
// Fall back to global settings
const globalSettings = await settingsService.getGlobalSettings();
const result = globalSettings.useClaudeCodeSystemPrompt ?? true;
logger.info(`${logPrefix} useClaudeCodeSystemPrompt from global settings: ${result}`);
return result;
} catch (error) {
logger.error(`${logPrefix} Failed to load useClaudeCodeSystemPrompt setting:`, error);
throw error;
}
}
/**
* Get the default max turns setting from global settings.
*
* Reads the user's configured `defaultMaxTurns` setting, which controls the maximum
* number of agent turns (tool-call round-trips) for feature execution.
*
* @param settingsService - Settings service instance (may be null)
* @param logPrefix - Logging prefix for debugging
* @returns The user's configured max turns, or {@link DEFAULT_MAX_TURNS} as default
*/
export async function getDefaultMaxTurnsSetting(
settingsService?: SettingsService | null,
logPrefix = '[SettingsHelper]'
): Promise<number> {
if (!settingsService) {
logger.info(
`${logPrefix} SettingsService not available, using default maxTurns=${DEFAULT_MAX_TURNS}`
);
return DEFAULT_MAX_TURNS;
}
try {
const globalSettings = await settingsService.getGlobalSettings();
const raw = globalSettings.defaultMaxTurns;
const result = Number.isFinite(raw) ? (raw as number) : DEFAULT_MAX_TURNS;
// Clamp to valid range
const clamped = Math.max(1, Math.min(MAX_ALLOWED_TURNS, Math.floor(result)));
logger.debug(`${logPrefix} defaultMaxTurns from global settings: ${clamped}`);
return clamped;
} catch (error) {
logger.error(`${logPrefix} Failed to load defaultMaxTurns setting:`, error);
return DEFAULT_MAX_TURNS;
}
}
/**
* Filters out CLAUDE.md from context files when autoLoadClaudeMd is enabled
* and rebuilds the formatted prompt without it.
@@ -604,6 +689,145 @@ export interface ProviderByModelIdResult {
resolvedModel: string | undefined;
}
/** Result from resolveProviderContext */
export interface ProviderContextResult {
/** The provider configuration */
provider: ClaudeCompatibleProvider | undefined;
/** Credentials for API key resolution */
credentials: Credentials | undefined;
/** The resolved Claude model ID for SDK configuration */
resolvedModel: string | undefined;
/** The original model config from the provider if found */
modelConfig: import('@automaker/types').ProviderModel | undefined;
}
/**
* Checks if a provider is enabled.
* Providers with enabled: undefined are treated as enabled (default state).
* Only explicitly set enabled: false means the provider is disabled.
*/
function isProviderEnabled(provider: ClaudeCompatibleProvider): boolean {
return provider.enabled !== false;
}
/**
* Finds a model config in a provider's models array by ID (case-insensitive).
*/
function findModelInProvider(
provider: ClaudeCompatibleProvider,
modelId: string
): import('@automaker/types').ProviderModel | undefined {
return provider.models?.find(
(m) => m.id === modelId || m.id.toLowerCase() === modelId.toLowerCase()
);
}
/**
* Resolves the provider and Claude-compatible model configuration.
*
* This is the central logic for resolving provider context, supporting:
* 1. Explicit lookup by providerId (most reliable for persistence)
* 2. Fallback lookup by modelId across all enabled providers
* 3. Resolution of mapsToClaudeModel for SDK configuration
*
* @param settingsService - Settings service instance
* @param modelId - The model ID to resolve
* @param providerId - Optional explicit provider ID
* @param logPrefix - Prefix for log messages
* @returns Promise resolving to the provider context
*/
export async function resolveProviderContext(
settingsService: SettingsService,
modelId: string,
providerId?: string,
logPrefix = '[SettingsHelper]'
): Promise<ProviderContextResult> {
try {
const globalSettings = await settingsService.getGlobalSettings();
const credentials = await settingsService.getCredentials();
const providers = globalSettings.claudeCompatibleProviders || [];
logger.debug(
`${logPrefix} Resolving provider context: modelId="${modelId}", providerId="${providerId ?? 'none'}", providers count=${providers.length}`
);
let provider: ClaudeCompatibleProvider | undefined;
let modelConfig: import('@automaker/types').ProviderModel | undefined;
// 1. Try resolving by explicit providerId first (most reliable)
if (providerId) {
provider = providers.find((p) => p.id === providerId);
if (provider) {
if (!isProviderEnabled(provider)) {
logger.warn(
`${logPrefix} Explicitly requested provider "${provider.name}" (${providerId}) is disabled (enabled=${provider.enabled})`
);
} else {
logger.debug(
`${logPrefix} Found provider "${provider.name}" (${providerId}), enabled=${provider.enabled ?? 'undefined (treated as enabled)'}`
);
// Find the model config within this provider to check for mappings
modelConfig = findModelInProvider(provider, modelId);
if (!modelConfig && provider.models && provider.models.length > 0) {
logger.debug(
`${logPrefix} Model "${modelId}" not found in provider "${provider.name}". Available models: ${provider.models.map((m) => m.id).join(', ')}`
);
}
}
} else {
logger.warn(
`${logPrefix} Explicitly requested provider "${providerId}" not found. Available providers: ${providers.map((p) => p.id).join(', ')}`
);
}
}
// 2. Fallback to model-based lookup across all providers if modelConfig not found
// Note: We still search even if provider was found, to get the modelConfig for mapping
if (!modelConfig) {
for (const p of providers) {
if (!isProviderEnabled(p) || p.id === providerId) continue; // Skip disabled or already checked
const config = findModelInProvider(p, modelId);
if (config) {
// Only override provider if we didn't find one by explicit ID
if (!provider) {
provider = p;
}
modelConfig = config;
logger.debug(`${logPrefix} Found model "${modelId}" in provider "${p.name}" (fallback)`);
break;
}
}
}
// 3. Resolve the mapped Claude model if specified
let resolvedModel: string | undefined;
if (modelConfig?.mapsToClaudeModel) {
const { resolveModelString } = await import('@automaker/model-resolver');
resolvedModel = resolveModelString(modelConfig.mapsToClaudeModel);
logger.debug(
`${logPrefix} Model "${modelId}" maps to Claude model "${modelConfig.mapsToClaudeModel}" -> "${resolvedModel}"`
);
}
// Log final result for debugging
logger.debug(
`${logPrefix} Provider context resolved: provider=${provider?.name ?? 'none'}, modelConfig=${modelConfig ? 'found' : 'not found'}, resolvedModel=${resolvedModel ?? modelId}`
);
return { provider, credentials, resolvedModel, modelConfig };
} catch (error) {
logger.error(`${logPrefix} Failed to resolve provider context:`, error);
return {
provider: undefined,
credentials: undefined,
resolvedModel: undefined,
modelConfig: undefined,
};
}
}
/**
* Find a ClaudeCompatibleProvider by one of its model IDs.
* Searches through all enabled providers to find one that contains the specified model.

View File

@@ -2,7 +2,7 @@
* Version utility - Reads version from package.json
*/
import { readFileSync } from 'fs';
import { readFileSync, existsSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { createLogger } from '@automaker/utils';
@@ -24,7 +24,20 @@ export function getVersion(): string {
}
try {
const packageJsonPath = join(__dirname, '..', '..', 'package.json');
const candidatePaths = [
// Development via tsx: src/lib -> project root
join(__dirname, '..', '..', 'package.json'),
// Packaged/build output: lib -> server bundle root
join(__dirname, '..', 'package.json'),
];
const packageJsonPath = candidatePaths.find((candidate) => existsSync(candidate));
if (!packageJsonPath) {
throw new Error(
`package.json not found in any expected location: ${candidatePaths.join(', ')}`
);
}
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
const version = packageJson.version || '0.0.0';
cachedVersion = version;

View File

@@ -78,7 +78,7 @@ export async function readWorktreeMetadata(
const metadataPath = getWorktreeMetadataPath(projectPath, branch);
const content = (await secureFs.readFile(metadataPath, 'utf-8')) as string;
return JSON.parse(content) as WorktreeMetadata;
} catch (error) {
} catch (_error) {
// File doesn't exist or can't be read
return null;
}

View File

@@ -5,11 +5,10 @@
* with the provider architecture.
*/
import { query, type Options } from '@anthropic-ai/claude-agent-sdk';
import { query, type Options, type SDKUserMessage } from '@anthropic-ai/claude-agent-sdk';
import { BaseProvider } from './base-provider.js';
import { classifyError, getUserFriendlyErrorMessage, createLogger } from '@automaker/utils';
const logger = createLogger('ClaudeProvider');
import { getClaudeAuthIndicators } from '@automaker/platform';
import {
getThinkingTokenBudget,
validateBareModelId,
@@ -17,6 +16,14 @@ import {
type ClaudeCompatibleProvider,
type Credentials,
} from '@automaker/types';
import type {
ExecuteOptions,
ProviderMessage,
InstallationStatus,
ModelDefinition,
} from './types.js';
const logger = createLogger('ClaudeProvider');
/**
* ProviderConfig - Union type for provider configuration
@@ -25,29 +32,11 @@ import {
* Both share the same connection settings structure.
*/
type ProviderConfig = ClaudeApiProfile | ClaudeCompatibleProvider;
import type {
ExecuteOptions,
ProviderMessage,
InstallationStatus,
ModelDefinition,
} from './types.js';
// 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_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)
// System vars are always passed from process.env regardless of profile.
// Includes filesystem, locale, and temp directory vars that the Claude CLI
// needs internally for config resolution and temp file creation.
const SYSTEM_ENV_VARS = [
'PATH',
'HOME',
'SHELL',
@@ -55,11 +44,13 @@ const ALLOWED_ENV_VARS = [
'USER',
'LANG',
'LC_ALL',
'TMPDIR',
'XDG_CONFIG_HOME',
'XDG_DATA_HOME',
'XDG_CACHE_HOME',
'XDG_STATE_HOME',
];
// System vars are always passed from process.env regardless of profile
const SYSTEM_ENV_VARS = ['PATH', 'HOME', 'SHELL', 'TERM', 'USER', 'LANG', 'LC_ALL'];
/**
* Check if the config is a ClaudeCompatibleProvider (new system)
* by checking for the 'models' array property
@@ -197,6 +188,7 @@ export class ClaudeProvider extends BaseProvider {
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
// Validate that model doesn't have a provider prefix
// AgentService should strip prefixes before passing to providers
// Claude doesn't use a provider prefix, so we don't need to specify an expected provider
validateBareModelId(options.model, 'ClaudeProvider');
const {
@@ -204,7 +196,7 @@ export class ClaudeProvider extends BaseProvider {
model,
cwd,
systemPrompt,
maxTurns = 20,
maxTurns = 1000,
allowedTools,
abortController,
conversationHistory,
@@ -219,8 +211,11 @@ export class ClaudeProvider extends BaseProvider {
// claudeCompatibleProvider takes precedence over claudeApiProfile
const providerConfig = claudeCompatibleProvider || claudeApiProfile;
// Convert thinking level to token budget
const maxThinkingTokens = getThinkingTokenBudget(thinkingLevel);
// Build thinking configuration
// Adaptive thinking (Opus 4.6): don't set maxThinkingTokens, model uses adaptive by default
// Manual thinking (Haiku/Sonnet): use budget_tokens
const maxThinkingTokens =
thinkingLevel === 'adaptive' ? undefined : getThinkingTokenBudget(thinkingLevel);
// Build Claude SDK options
const sdkOptions: Options = {
@@ -234,6 +229,8 @@ export class ClaudeProvider extends BaseProvider {
env: buildEnv(providerConfig, credentials),
// Pass through allowedTools if provided by caller (decided by sdk-options.ts)
...(allowedTools && { allowedTools }),
// Restrict available built-in tools if specified (tools: [] disables all tools)
...(options.tools && { tools: options.tools }),
// AUTONOMOUS MODE: Always bypass permissions for fully autonomous operation
permissionMode: 'bypassPermissions',
allowDangerouslySkipPermissions: true,
@@ -255,14 +252,14 @@ export class ClaudeProvider extends BaseProvider {
};
// Build prompt payload
let promptPayload: string | AsyncIterable<any>;
let promptPayload: string | AsyncIterable<SDKUserMessage>;
if (Array.isArray(prompt)) {
// Multi-part prompt (with images)
promptPayload = (async function* () {
const multiPartPrompt = {
const multiPartPrompt: SDKUserMessage = {
type: 'user' as const,
session_id: '',
session_id: sdkSessionId || '',
message: {
role: 'user' as const,
content: prompt,
@@ -314,12 +311,16 @@ export class ClaudeProvider extends BaseProvider {
? `${userMessage}\n\nTip: If you're running multiple features in auto-mode, consider reducing concurrency (maxConcurrency setting) to avoid hitting rate limits.`
: userMessage;
const enhancedError = new Error(message);
(enhancedError as any).originalError = error;
(enhancedError as any).type = errorInfo.type;
const enhancedError = new Error(message) as Error & {
originalError: unknown;
type: string;
retryAfter?: number;
};
enhancedError.originalError = error;
enhancedError.type = errorInfo.type;
if (errorInfo.isRateLimit) {
(enhancedError as any).retryAfter = errorInfo.retryAfter;
enhancedError.retryAfter = errorInfo.retryAfter;
}
throw enhancedError;
@@ -331,13 +332,37 @@ export class ClaudeProvider extends BaseProvider {
*/
async detectInstallation(): Promise<InstallationStatus> {
// Claude SDK is always available since it's a dependency
const hasApiKey = !!process.env.ANTHROPIC_API_KEY;
// Check all four supported auth methods, mirroring the logic in buildEnv():
// 1. ANTHROPIC_API_KEY environment variable
// 2. ANTHROPIC_AUTH_TOKEN environment variable
// 3. credentials?.apiKeys?.anthropic (credentials file, checked via platform indicators)
// 4. Claude Max CLI OAuth (SDK handles this automatically; detected via getClaudeAuthIndicators)
const hasEnvApiKey = !!process.env.ANTHROPIC_API_KEY;
const hasEnvAuthToken = !!process.env.ANTHROPIC_AUTH_TOKEN;
// Check credentials file and CLI OAuth indicators (same sources used by buildEnv)
let hasCredentialsApiKey = false;
let hasCliOAuth = false;
try {
const indicators = await getClaudeAuthIndicators();
hasCredentialsApiKey = !!indicators.credentials?.hasApiKey;
hasCliOAuth = !!(
indicators.credentials?.hasOAuthToken ||
indicators.hasStatsCacheWithActivity ||
(indicators.hasSettingsFile && indicators.hasProjectsSessions)
);
} catch {
// If we can't check indicators, fall back to env vars only
}
const hasApiKey = hasEnvApiKey || hasCredentialsApiKey;
const authenticated = hasEnvApiKey || hasEnvAuthToken || hasCredentialsApiKey || hasCliOAuth;
const status: InstallationStatus = {
installed: true,
method: 'sdk',
hasApiKey,
authenticated: hasApiKey,
authenticated,
};
return status;
@@ -349,18 +374,30 @@ export class ClaudeProvider extends BaseProvider {
getAvailableModels(): ModelDefinition[] {
const models = [
{
id: 'claude-opus-4-5-20251101',
name: 'Claude Opus 4.5',
modelString: 'claude-opus-4-5-20251101',
id: 'claude-opus-4-6',
name: 'Claude Opus 4.6',
modelString: 'claude-opus-4-6',
provider: 'anthropic',
description: 'Most capable Claude model',
description: 'Most capable Claude model with adaptive thinking',
contextWindow: 200000,
maxOutputTokens: 16000,
maxOutputTokens: 128000,
supportsVision: true,
supportsTools: true,
tier: 'premium' as const,
default: true,
},
{
id: 'claude-sonnet-4-6',
name: 'Claude Sonnet 4.6',
modelString: 'claude-sonnet-4-6',
provider: 'anthropic',
description: 'Balanced performance and cost with enhanced reasoning',
contextWindow: 200000,
maxOutputTokens: 64000,
supportsVision: true,
supportsTools: true,
tier: 'standard' as const,
},
{
id: 'claude-sonnet-4-20250514',
name: 'Claude Sonnet 4',

View File

@@ -19,12 +19,11 @@ const MAX_OUTPUT_16K = 16000;
export const CODEX_MODELS: ModelDefinition[] = [
// ========== Recommended Codex Models ==========
{
id: CODEX_MODEL_MAP.gpt52Codex,
name: 'GPT-5.2-Codex',
modelString: CODEX_MODEL_MAP.gpt52Codex,
id: CODEX_MODEL_MAP.gpt53Codex,
name: 'GPT-5.3-Codex',
modelString: CODEX_MODEL_MAP.gpt53Codex,
provider: 'openai',
description:
'Most advanced agentic coding model for complex software engineering (default for ChatGPT users).',
description: 'Latest frontier agentic coding model.',
contextWindow: CONTEXT_WINDOW_256K,
maxOutputTokens: MAX_OUTPUT_32K,
supportsVision: true,
@@ -33,12 +32,38 @@ export const CODEX_MODELS: ModelDefinition[] = [
default: true,
hasReasoning: true,
},
{
id: CODEX_MODEL_MAP.gpt53CodexSpark,
name: 'GPT-5.3-Codex-Spark',
modelString: CODEX_MODEL_MAP.gpt53CodexSpark,
provider: 'openai',
description: 'Near-instant real-time coding model, 1000+ tokens/sec.',
contextWindow: CONTEXT_WINDOW_256K,
maxOutputTokens: MAX_OUTPUT_32K,
supportsVision: true,
supportsTools: true,
tier: 'premium' as const,
hasReasoning: true,
},
{
id: CODEX_MODEL_MAP.gpt52Codex,
name: 'GPT-5.2-Codex',
modelString: CODEX_MODEL_MAP.gpt52Codex,
provider: 'openai',
description: 'Frontier agentic coding model.',
contextWindow: CONTEXT_WINDOW_256K,
maxOutputTokens: MAX_OUTPUT_32K,
supportsVision: true,
supportsTools: true,
tier: 'premium' as const,
hasReasoning: true,
},
{
id: CODEX_MODEL_MAP.gpt51CodexMax,
name: 'GPT-5.1-Codex-Max',
modelString: CODEX_MODEL_MAP.gpt51CodexMax,
provider: 'openai',
description: 'Optimized for long-horizon, agentic coding tasks in Codex.',
description: 'Codex-optimized flagship for deep and fast reasoning.',
contextWindow: CONTEXT_WINDOW_256K,
maxOutputTokens: MAX_OUTPUT_32K,
supportsVision: true,
@@ -51,7 +76,46 @@ export const CODEX_MODELS: ModelDefinition[] = [
name: 'GPT-5.1-Codex-Mini',
modelString: CODEX_MODEL_MAP.gpt51CodexMini,
provider: 'openai',
description: 'Smaller, more cost-effective version for faster workflows.',
description: 'Optimized for codex. Cheaper, faster, but less capable.',
contextWindow: CONTEXT_WINDOW_128K,
maxOutputTokens: MAX_OUTPUT_16K,
supportsVision: true,
supportsTools: true,
tier: 'basic' as const,
hasReasoning: false,
},
{
id: CODEX_MODEL_MAP.gpt51Codex,
name: 'GPT-5.1-Codex',
modelString: CODEX_MODEL_MAP.gpt51Codex,
provider: 'openai',
description: 'Original GPT-5.1 Codex agentic coding model.',
contextWindow: CONTEXT_WINDOW_256K,
maxOutputTokens: MAX_OUTPUT_32K,
supportsVision: true,
supportsTools: true,
tier: 'standard' as const,
hasReasoning: true,
},
{
id: CODEX_MODEL_MAP.gpt5Codex,
name: 'GPT-5-Codex',
modelString: CODEX_MODEL_MAP.gpt5Codex,
provider: 'openai',
description: 'Original GPT-5 Codex model.',
contextWindow: CONTEXT_WINDOW_128K,
maxOutputTokens: MAX_OUTPUT_16K,
supportsVision: true,
supportsTools: true,
tier: 'standard' as const,
hasReasoning: true,
},
{
id: CODEX_MODEL_MAP.gpt5CodexMini,
name: 'GPT-5-Codex-Mini',
modelString: CODEX_MODEL_MAP.gpt5CodexMini,
provider: 'openai',
description: 'Smaller, cheaper GPT-5 Codex variant.',
contextWindow: CONTEXT_WINDOW_128K,
maxOutputTokens: MAX_OUTPUT_16K,
supportsVision: true,
@@ -66,7 +130,7 @@ export const CODEX_MODELS: ModelDefinition[] = [
name: 'GPT-5.2',
modelString: CODEX_MODEL_MAP.gpt52,
provider: 'openai',
description: 'Best general agentic model for tasks across industries and domains.',
description: 'Latest frontier model with improvements across knowledge, reasoning and coding.',
contextWindow: CONTEXT_WINDOW_256K,
maxOutputTokens: MAX_OUTPUT_32K,
supportsVision: true,
@@ -87,6 +151,19 @@ export const CODEX_MODELS: ModelDefinition[] = [
tier: 'standard' as const,
hasReasoning: true,
},
{
id: CODEX_MODEL_MAP.gpt5,
name: 'GPT-5',
modelString: CODEX_MODEL_MAP.gpt5,
provider: 'openai',
description: 'Base GPT-5 model.',
contextWindow: CONTEXT_WINDOW_128K,
maxOutputTokens: MAX_OUTPUT_16K,
supportsVision: true,
supportsTools: true,
tier: 'standard' as const,
hasReasoning: true,
},
];
/**

View File

@@ -30,11 +30,9 @@ import type {
ModelDefinition,
} from './types.js';
import {
CODEX_MODEL_MAP,
supportsReasoningEffort,
validateBareModelId,
calculateReasoningTimeout,
DEFAULT_TIMEOUT_MS,
type CodexApprovalPolicy,
type CodexSandboxMode,
type CodexAuthStatus,
@@ -53,18 +51,14 @@ import { CODEX_MODELS } from './codex-models.js';
const CODEX_COMMAND = 'codex';
const CODEX_EXEC_SUBCOMMAND = 'exec';
const CODEX_RESUME_SUBCOMMAND = 'resume';
const CODEX_JSON_FLAG = '--json';
const CODEX_MODEL_FLAG = '--model';
const CODEX_VERSION_FLAG = '--version';
const CODEX_SANDBOX_FLAG = '--sandbox';
const CODEX_APPROVAL_FLAG = '--ask-for-approval';
const CODEX_SEARCH_FLAG = '--search';
const CODEX_OUTPUT_SCHEMA_FLAG = '--output-schema';
const CODEX_CONFIG_FLAG = '--config';
const CODEX_IMAGE_FLAG = '--image';
const CODEX_ADD_DIR_FLAG = '--add-dir';
const CODEX_OUTPUT_SCHEMA_FLAG = '--output-schema';
const CODEX_SKIP_GIT_REPO_CHECK_FLAG = '--skip-git-repo-check';
const CODEX_RESUME_FLAG = 'resume';
const CODEX_REASONING_EFFORT_KEY = 'reasoning_effort';
const CODEX_YOLO_FLAG = '--dangerously-bypass-approvals-and-sandbox';
const OPENAI_API_KEY_ENV = 'OPENAI_API_KEY';
@@ -104,11 +98,8 @@ const TEXT_ENCODING = 'utf-8';
*
* @see calculateReasoningTimeout from @automaker/types
*/
const CODEX_CLI_TIMEOUT_MS = DEFAULT_TIMEOUT_MS;
const CODEX_CLI_TIMEOUT_MS = 120000; // 2 minutes — matches CLI provider base timeout
const CODEX_FEATURE_GENERATION_BASE_TIMEOUT_MS = 300000; // 5 minutes for feature generation
const CONTEXT_WINDOW_256K = 256000;
const MAX_OUTPUT_32K = 32000;
const MAX_OUTPUT_16K = 16000;
const SYSTEM_PROMPT_SEPARATOR = '\n\n';
const CODEX_INSTRUCTIONS_DIR = '.codex';
const CODEX_INSTRUCTIONS_SECTION = 'Codex Project Instructions';
@@ -136,11 +127,16 @@ const DEFAULT_ALLOWED_TOOLS = [
'Read',
'Write',
'Edit',
'MultiEdit',
'Glob',
'Grep',
'LS',
'Bash',
'WebSearch',
'WebFetch',
'TodoWrite',
'Task',
'Skill',
] as const;
const SEARCH_TOOL_NAMES = new Set(['WebSearch', 'WebFetch']);
const MIN_MAX_TURNS = 1;
@@ -210,16 +206,42 @@ function isSdkEligible(options: ExecuteOptions): boolean {
return isNoToolsRequested(options) && !hasMcpServersConfigured(options);
}
function isSdkEligibleWithApiKey(options: ExecuteOptions): boolean {
// When using an API key (not CLI OAuth), prefer SDK over CLI to avoid OAuth issues.
// SDK mode is used when MCP servers are not configured (MCP requires CLI).
// Tool requests are handled by the SDK, so we allow SDK mode even with tools.
return !hasMcpServersConfigured(options);
}
async function resolveCodexExecutionPlan(options: ExecuteOptions): Promise<CodexExecutionPlan> {
const cliPath = await findCodexCliPath();
const authIndicators = await getCodexAuthIndicators();
const openAiApiKey = await resolveOpenAiApiKey();
const hasApiKey = Boolean(openAiApiKey);
const cliAuthenticated = authIndicators.hasOAuthToken || authIndicators.hasApiKey || hasApiKey;
const sdkEligible = isSdkEligible(options);
const cliAvailable = Boolean(cliPath);
// CLI OAuth login takes priority: if the user has logged in via `codex login`,
// use the CLI regardless of whether an API key is also stored.
// hasOAuthToken = OAuth session from `codex login`
// authIndicators.hasApiKey = API key stored in Codex's own auth file (via `codex login --api-key`)
// Both are "CLI-native" auth — distinct from an API key stored in Automaker's credentials.
const hasCliNativeAuth = authIndicators.hasOAuthToken || authIndicators.hasApiKey;
const sdkEligible = isSdkEligible(options);
if (hasApiKey) {
// If CLI is available and the user authenticated via the CLI (`codex login`),
// prefer CLI mode over SDK. This ensures `codex login` sessions take priority
// over API keys stored in Automaker's credentials.
if (cliAvailable && hasCliNativeAuth) {
return {
mode: CODEX_EXECUTION_MODE_CLI,
cliPath,
openAiApiKey,
};
}
// No CLI-native auth — prefer SDK when an API key is available.
// Using SDK with an API key avoids OAuth issues that can arise with the CLI.
// MCP servers still require CLI mode since the SDK doesn't support MCP.
if (hasApiKey && isSdkEligibleWithApiKey(options)) {
return {
mode: CODEX_EXECUTION_MODE_SDK,
cliPath,
@@ -227,6 +249,16 @@ async function resolveCodexExecutionPlan(options: ExecuteOptions): Promise<Codex
};
}
// MCP servers are requested with an API key but no CLI-native auth — use CLI mode
// with the API key passed as an environment variable.
if (hasApiKey && cliAvailable) {
return {
mode: CODEX_EXECUTION_MODE_CLI,
cliPath,
openAiApiKey,
};
}
if (sdkEligible) {
if (!cliAvailable) {
throw new Error(ERROR_CODEX_SDK_AUTH_REQUIRED);
@@ -237,15 +269,9 @@ async function resolveCodexExecutionPlan(options: ExecuteOptions): Promise<Codex
throw new Error(ERROR_CODEX_CLI_REQUIRED);
}
if (!cliAuthenticated) {
throw new Error(ERROR_CODEX_AUTH_REQUIRED);
}
return {
mode: CODEX_EXECUTION_MODE_CLI,
cliPath,
openAiApiKey,
};
// At this point, neither hasCliNativeAuth nor hasApiKey is true,
// so authentication is required regardless.
throw new Error(ERROR_CODEX_AUTH_REQUIRED);
}
function getEventType(event: Record<string, unknown>): string | null {
@@ -335,9 +361,14 @@ function resolveSystemPrompt(systemPrompt?: unknown): string | null {
return null;
}
function buildPromptText(options: ExecuteOptions): string {
return typeof options.prompt === 'string'
? options.prompt
: extractTextFromContent(options.prompt);
}
function buildCombinedPrompt(options: ExecuteOptions, systemPromptText?: string | null): string {
const promptText =
typeof options.prompt === 'string' ? options.prompt : extractTextFromContent(options.prompt);
const promptText = buildPromptText(options);
const historyText = options.conversationHistory
? formatHistoryAsText(options.conversationHistory)
: '';
@@ -350,6 +381,11 @@ function buildCombinedPrompt(options: ExecuteOptions, systemPromptText?: string
return `${historyText}${systemSection}${HISTORY_HEADER}${promptText}`;
}
function buildResumePrompt(options: ExecuteOptions): string {
const promptText = buildPromptText(options);
return `${HISTORY_HEADER}${promptText}`;
}
function formatConfigValue(value: string | number | boolean): string {
return String(value);
}
@@ -703,9 +739,9 @@ export class CodexProvider extends BaseProvider {
}
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
// Validate that model doesn't have a provider prefix
// Validate that model doesn't have a provider prefix (except codex- which should already be stripped)
// AgentService should strip prefixes before passing to providers
validateBareModelId(options.model, 'CodexProvider');
validateBareModelId(options.model, 'CodexProvider', 'codex');
try {
const mcpServers = options.mcpServers ?? {};
@@ -717,6 +753,16 @@ export class CodexProvider extends BaseProvider {
);
const baseSystemPrompt = resolveSystemPrompt(options.systemPrompt);
const resolvedMaxTurns = resolveMaxTurns(options.maxTurns);
if (resolvedMaxTurns === null && options.maxTurns === undefined) {
logger.warn(
`[executeQuery] maxTurns not provided — Codex CLI will use its internal default. ` +
`This may cause premature completion. Model: ${options.model}`
);
} else {
logger.info(
`[executeQuery] maxTurns: requested=${options.maxTurns}, resolved=${resolvedMaxTurns}, model=${options.model}`
);
}
const resolvedAllowedTools = options.allowedTools ?? Array.from(DEFAULT_ALLOWED_TOOLS);
const restrictTools = !hasMcpServers || options.mcpUnrestrictedTools === false;
const wantsOutputSchema = Boolean(
@@ -758,24 +804,27 @@ export class CodexProvider extends BaseProvider {
options.cwd,
codexSettings.sandboxMode !== 'danger-full-access'
);
const resolvedSandboxMode = sandboxCheck.enabled
? codexSettings.sandboxMode
: 'danger-full-access';
if (!sandboxCheck.enabled && sandboxCheck.message) {
console.warn(`[CodexProvider] ${sandboxCheck.message}`);
}
const searchEnabled =
codexSettings.enableWebSearch || resolveSearchEnabled(resolvedAllowedTools, restrictTools);
const outputSchemaPath = await writeOutputSchemaFile(options.cwd, options.outputFormat);
const imageBlocks = codexSettings.enableImages ? extractImageBlocks(options.prompt) : [];
const imagePaths = await writeImageFiles(options.cwd, imageBlocks);
const isResumeQuery = Boolean(options.sdkSessionId);
const schemaPath = isResumeQuery
? null
: await writeOutputSchemaFile(options.cwd, options.outputFormat);
const imageBlocks =
!isResumeQuery && codexSettings.enableImages ? extractImageBlocks(options.prompt) : [];
const imagePaths = isResumeQuery ? [] : await writeImageFiles(options.cwd, imageBlocks);
const approvalPolicy =
hasMcpServers && options.mcpAutoApproveTools !== undefined
? options.mcpAutoApproveTools
? 'never'
: 'on-request'
: codexSettings.approvalPolicy;
const promptText = buildCombinedPrompt(options, combinedSystemPrompt);
const promptText = isResumeQuery
? buildResumePrompt(options)
: buildCombinedPrompt(options, combinedSystemPrompt);
const commandPath = executionPlan.cliPath || CODEX_COMMAND;
// Build config overrides for max turns and reasoning effort
@@ -801,25 +850,43 @@ export class CodexProvider extends BaseProvider {
overrides.push({ key: 'features.web_search_request', value: true });
}
const configOverrides = buildConfigOverrides(overrides);
const configOverrideArgs = buildConfigOverrides(overrides);
const preExecArgs: string[] = [];
// Add additional directories with write access
if (codexSettings.additionalDirs && codexSettings.additionalDirs.length > 0) {
if (
!isResumeQuery &&
codexSettings.additionalDirs &&
codexSettings.additionalDirs.length > 0
) {
for (const dir of codexSettings.additionalDirs) {
preExecArgs.push(CODEX_ADD_DIR_FLAG, dir);
}
}
// If images were written to disk, add the image directory so the CLI can access them.
// Note: imagePaths is set to [] when isResumeQuery is true, so this check is sufficient.
if (imagePaths.length > 0) {
const imageDir = path.join(options.cwd, CODEX_INSTRUCTIONS_DIR, IMAGE_TEMP_DIR);
preExecArgs.push(CODEX_ADD_DIR_FLAG, imageDir);
}
// Model is already bare (no prefix) - validated by executeQuery
const codexCommand = isResumeQuery
? [CODEX_EXEC_SUBCOMMAND, CODEX_RESUME_SUBCOMMAND]
: [CODEX_EXEC_SUBCOMMAND];
const args = [
CODEX_EXEC_SUBCOMMAND,
...codexCommand,
CODEX_YOLO_FLAG,
CODEX_SKIP_GIT_REPO_CHECK_FLAG,
...preExecArgs,
CODEX_MODEL_FLAG,
options.model,
CODEX_JSON_FLAG,
...configOverrideArgs,
...(schemaPath ? [CODEX_OUTPUT_SCHEMA_FLAG, schemaPath] : []),
...(options.sdkSessionId ? [options.sdkSessionId] : []),
'-', // Read prompt from stdin to avoid shell escaping issues
];
@@ -866,16 +933,36 @@ export class CodexProvider extends BaseProvider {
// Enhance error message with helpful context
let enhancedError = errorText;
if (errorText.toLowerCase().includes('rate limit')) {
const errorLower = errorText.toLowerCase();
if (errorLower.includes('rate limit')) {
enhancedError = `${errorText}\n\nTip: You're being rate limited. Try reducing concurrent tasks or waiting a few minutes before retrying.`;
} else if (errorLower.includes('authentication') || errorLower.includes('unauthorized')) {
enhancedError = `${errorText}\n\nTip: Check that your OPENAI_API_KEY is set correctly or run 'codex login' to authenticate.`;
} else if (
errorText.toLowerCase().includes('authentication') ||
errorText.toLowerCase().includes('unauthorized')
errorLower.includes('model does not exist') ||
errorLower.includes('requested model does not exist') ||
errorLower.includes('do not have access') ||
errorLower.includes('model_not_found') ||
errorLower.includes('invalid_model')
) {
enhancedError = `${errorText}\n\nTip: Check that your OPENAI_API_KEY is set correctly or run 'codex auth login' to authenticate.`;
enhancedError =
`${errorText}\n\nTip: The model '${options.model}' may not be available on your OpenAI plan. ` +
`See https://platform.openai.com/docs/models for available models. ` +
`Some models require a ChatGPT Pro/Plus subscription—authenticate with 'codex login' instead of an API key.`;
} else if (
errorText.toLowerCase().includes('not found') ||
errorText.toLowerCase().includes('command not found')
errorLower.includes('stream disconnected') ||
errorLower.includes('stream ended') ||
errorLower.includes('connection reset')
) {
enhancedError =
`${errorText}\n\nTip: The connection to OpenAI was interrupted. This can happen due to:\n` +
`- Network instability\n` +
`- The model not being available on your plan\n` +
`- Server-side timeouts for long-running requests\n` +
`Try again, or switch to a different model.`;
} else if (
errorLower.includes('command not found') ||
errorLower.includes('is not recognized as an internal or external command')
) {
enhancedError = `${errorText}\n\nTip: Make sure the Codex CLI is installed. Run 'npm install -g @openai/codex-cli' to install.`;
}
@@ -1033,7 +1120,6 @@ export class CodexProvider extends BaseProvider {
async detectInstallation(): Promise<InstallationStatus> {
const cliPath = await findCodexCliPath();
const hasApiKey = Boolean(await resolveOpenAiApiKey());
const authIndicators = await getCodexAuthIndicators();
const installed = !!cliPath;
let version = '';
@@ -1045,7 +1131,7 @@ export class CodexProvider extends BaseProvider {
cwd: process.cwd(),
});
version = result.stdout.trim();
} catch (error) {
} catch {
version = '';
}
}

View File

@@ -15,6 +15,9 @@ const SDK_HISTORY_HEADER = 'Current request:\n';
const DEFAULT_RESPONSE_TEXT = '';
const SDK_ERROR_DETAILS_LABEL = 'Details:';
type SdkReasoningEffort = 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
const SDK_REASONING_EFFORTS = new Set<string>(['minimal', 'low', 'medium', 'high', 'xhigh']);
type PromptBlock = {
type: string;
text?: string;
@@ -99,38 +102,52 @@ export async function* executeCodexSdkQuery(
const apiKey = resolveApiKey();
const codex = new Codex({ apiKey });
// Build thread options with model
// The model must be passed to startThread/resumeThread so the SDK
// knows which model to use for the conversation. Without this,
// the SDK may use a default model that the user doesn't have access to.
const threadOptions: {
model?: string;
modelReasoningEffort?: SdkReasoningEffort;
} = {};
if (options.model) {
threadOptions.model = options.model;
}
// Add reasoning effort to thread options if model supports it
if (
options.reasoningEffort &&
options.model &&
supportsReasoningEffort(options.model) &&
options.reasoningEffort !== 'none' &&
SDK_REASONING_EFFORTS.has(options.reasoningEffort)
) {
threadOptions.modelReasoningEffort = options.reasoningEffort as SdkReasoningEffort;
}
// Resume existing thread or start new one
let thread;
if (options.sdkSessionId) {
try {
thread = codex.resumeThread(options.sdkSessionId);
thread = codex.resumeThread(options.sdkSessionId, threadOptions);
} catch {
// If resume fails, start a new thread
thread = codex.startThread();
thread = codex.startThread(threadOptions);
}
} else {
thread = codex.startThread();
thread = codex.startThread(threadOptions);
}
const promptText = buildPromptText(options, systemPrompt);
// Build run options with reasoning effort if supported
// Build run options
const runOptions: {
signal?: AbortSignal;
reasoning?: { effort: string };
} = {
signal: options.abortController?.signal,
};
// Add reasoning effort if model supports it and reasoningEffort is specified
if (
options.reasoningEffort &&
supportsReasoningEffort(options.model) &&
options.reasoningEffort !== 'none'
) {
runOptions.reasoning = { effort: options.reasoningEffort };
}
// Run the query
const result = await thread.run(promptText, runOptions);
@@ -160,10 +177,42 @@ export async function* executeCodexSdkQuery(
} catch (error) {
const errorInfo = classifyError(error);
const userMessage = getUserFriendlyErrorMessage(error);
const combinedMessage = buildSdkErrorMessage(errorInfo.message, userMessage);
let combinedMessage = buildSdkErrorMessage(errorInfo.message, userMessage);
// Enhance error messages with actionable tips for common Codex issues
// Normalize inputs to avoid crashes from nullish values
const errorLower = (errorInfo?.message ?? '').toLowerCase();
const modelLabel = options?.model ?? '<unknown model>';
if (
errorLower.includes('does not exist') ||
errorLower.includes('model_not_found') ||
errorLower.includes('invalid_model')
) {
// Model not found - provide helpful guidance
combinedMessage +=
`\n\nTip: The model '${modelLabel}' may not be available on your OpenAI plan. ` +
`Some models (like gpt-5.3-codex) require a ChatGPT Pro/Plus subscription and OAuth login via 'codex login'. ` +
`Try using a different model (e.g., gpt-5.1 or gpt-5.2), or authenticate with 'codex login' instead of an API key.`;
} else if (
errorLower.includes('stream disconnected') ||
errorLower.includes('stream ended') ||
errorLower.includes('connection reset') ||
errorLower.includes('socket hang up')
) {
// Stream disconnection - provide helpful guidance
combinedMessage +=
`\n\nTip: The connection to OpenAI was interrupted. This can happen due to:\n` +
`- Network instability\n` +
`- The model not being available on your plan (try 'codex login' for OAuth authentication)\n` +
`- Server-side timeouts for long-running requests\n` +
`Try again, or switch to a different model.`;
}
console.error('[CodexSDK] executeQuery() error during execution:', {
type: errorInfo.type,
message: errorInfo.message,
model: options.model,
isRateLimit: errorInfo.isRateLimit,
retryAfter: errorInfo.retryAfter,
stack: error instanceof Error ? error.stack : undefined,

View File

@@ -30,6 +30,7 @@ import {
type CopilotRuntimeModel,
} from '@automaker/types';
import { createLogger, isAbortError } from '@automaker/utils';
import { resolveModelString } from '@automaker/model-resolver';
import { CopilotClient, type PermissionRequest } from '@github/copilot-sdk';
import {
normalizeTodos,
@@ -42,7 +43,7 @@ import {
const logger = createLogger('CopilotProvider');
// Default bare model (without copilot- prefix) for SDK calls
const DEFAULT_BARE_MODEL = 'claude-sonnet-4.5';
const DEFAULT_BARE_MODEL = 'claude-sonnet-4.6';
// =============================================================================
// SDK Event Types (from @github/copilot-sdk)
@@ -75,20 +76,21 @@ interface SdkToolExecutionStartEvent extends SdkEvent {
};
}
interface SdkToolExecutionEndEvent extends SdkEvent {
type: 'tool.execution_end';
interface SdkToolExecutionCompleteEvent extends SdkEvent {
type: 'tool.execution_complete';
data: {
toolName: string;
toolCallId: string;
result?: string;
error?: string;
success: boolean;
result?: {
content: string;
};
error?: {
message: string;
code?: string;
};
};
}
interface SdkSessionIdleEvent extends SdkEvent {
type: 'session.idle';
}
interface SdkSessionErrorEvent extends SdkEvent {
type: 'session.error';
data: {
@@ -97,6 +99,16 @@ interface SdkSessionErrorEvent extends SdkEvent {
};
}
// =============================================================================
// Constants
// =============================================================================
/**
* Prefix for error messages in tool results
* Consistent with GeminiProvider's error formatting
*/
const TOOL_ERROR_PREFIX = '[ERROR]' as const;
// =============================================================================
// Error Codes
// =============================================================================
@@ -120,6 +132,12 @@ export interface CopilotError extends Error {
suggestion?: string;
}
type CopilotSession = Awaited<ReturnType<CopilotClient['createSession']>>;
type CopilotSessionOptions = Parameters<CopilotClient['createSession']>[0];
type ResumableCopilotClient = CopilotClient & {
resumeSession?: (sessionId: string, options: CopilotSessionOptions) => Promise<CopilotSession>;
};
// =============================================================================
// Tool Name Normalization
// =============================================================================
@@ -354,12 +372,19 @@ export class CopilotProvider extends CliProvider {
};
}
case 'tool.execution_end': {
const toolResultEvent = sdkEvent as SdkToolExecutionEndEvent;
const isError = !!toolResultEvent.data.error;
const content = isError
? `[ERROR] ${toolResultEvent.data.error}`
: toolResultEvent.data.result || '';
/**
* Tool execution completed event
* Handles both successful results and errors from tool executions
* Error messages optionally include error codes for better debugging
*/
case 'tool.execution_complete': {
const toolResultEvent = sdkEvent as SdkToolExecutionCompleteEvent;
const error = toolResultEvent.data.error;
// Format error message with optional code for better debugging
const content = error
? `${TOOL_ERROR_PREFIX} ${error.message}${error.code ? ` (${error.code})` : ''}`
: toolResultEvent.data.result?.content || '';
return {
type: 'assistant',
@@ -386,9 +411,14 @@ export class CopilotProvider extends CliProvider {
case 'session.error': {
const errorEvent = sdkEvent as SdkSessionErrorEvent;
const enrichedError =
errorEvent.data.message ||
(errorEvent.data.code
? `Copilot agent error (code: ${errorEvent.data.code})`
: 'Copilot agent error');
return {
type: 'error',
error: errorEvent.data.message || 'Unknown error',
error: enrichedError,
};
}
@@ -520,7 +550,11 @@ export class CopilotProvider extends CliProvider {
}
const promptText = this.extractPromptText(options);
const bareModel = options.model || DEFAULT_BARE_MODEL;
// resolveModelString may return dash-separated canonical names (e.g. "claude-sonnet-4-6"),
// but the Copilot SDK expects dot-separated version suffixes (e.g. "claude-sonnet-4.6").
// Normalize by converting the last dash-separated numeric pair to dot notation.
const resolvedModel = resolveModelString(options.model || DEFAULT_BARE_MODEL);
const bareModel = resolvedModel.replace(/-(\d+)-(\d+)$/, '-$1.$2');
const workingDirectory = options.cwd || process.cwd();
logger.debug(
@@ -558,12 +592,14 @@ export class CopilotProvider extends CliProvider {
});
};
// Declare session outside try so it's accessible in the catch block for cleanup.
let session: CopilotSession | undefined;
try {
await client.start();
logger.debug(`CopilotClient started with cwd: ${workingDirectory}`);
// Create session with streaming enabled for real-time events
const session = await client.createSession({
const sessionOptions: CopilotSessionOptions = {
model: bareModel,
streaming: true,
// AUTONOMOUS MODE: Auto-approve all permission requests.
@@ -576,13 +612,33 @@ export class CopilotProvider extends CliProvider {
logger.debug(`Permission request: ${request.kind}`);
return { kind: 'approved' };
},
});
};
const sessionId = session.sessionId;
logger.debug(`Session created: ${sessionId}`);
// Resume the previous Copilot session when possible; otherwise create a fresh one.
const resumableClient = client as ResumableCopilotClient;
let sessionResumed = false;
if (options.sdkSessionId && typeof resumableClient.resumeSession === 'function') {
try {
session = await resumableClient.resumeSession(options.sdkSessionId, sessionOptions);
sessionResumed = true;
logger.debug(`Resumed Copilot session: ${session.sessionId}`);
} catch (resumeError) {
logger.warn(
`Failed to resume Copilot session "${options.sdkSessionId}", creating a new session: ${resumeError}`
);
session = await client.createSession(sessionOptions);
}
} else {
session = await client.createSession(sessionOptions);
}
// session is always assigned by this point (both branches above assign it)
const activeSession = session!;
const sessionId = activeSession.sessionId;
logger.debug(`Session ${sessionResumed ? 'resumed' : 'created'}: ${sessionId}`);
// Set up event handler to push events to queue
session.on((event: SdkEvent) => {
activeSession.on((event: SdkEvent) => {
logger.debug(`SDK event: ${event.type}`);
if (event.type === 'session.idle') {
@@ -594,13 +650,13 @@ export class CopilotProvider extends CliProvider {
sessionComplete = true;
pushEvent(event);
} else {
// Push all other events (tool.execution_start, tool.execution_end, assistant.message, etc.)
// Push all other events (tool.execution_start, tool.execution_complete, assistant.message, etc.)
pushEvent(event);
}
});
// Send the prompt (non-blocking)
await session.send({ prompt: promptText });
await activeSession.send({ prompt: promptText });
// Process events as they arrive
while (!sessionComplete || eventQueue.length > 0) {
@@ -608,7 +664,7 @@ export class CopilotProvider extends CliProvider {
// Check for errors first (before processing events to avoid race condition)
if (sessionError) {
await session.destroy();
await activeSession.destroy();
await client.stop();
throw sessionError;
}
@@ -628,11 +684,19 @@ export class CopilotProvider extends CliProvider {
}
// Cleanup
await session.destroy();
await activeSession.destroy();
await client.stop();
logger.debug('CopilotClient stopped successfully');
} catch (error) {
// Ensure client is stopped on error
// Ensure session is destroyed and client is stopped on error to prevent leaks.
// The session may have been created/resumed before the error occurred.
if (session) {
try {
await session.destroy();
} catch (sessionCleanupError) {
logger.debug(`Failed to destroy session during cleanup: ${sessionCleanupError}`);
}
}
try {
await client.stop();
} catch (cleanupError) {

View File

@@ -31,7 +31,7 @@ import type {
} from './types.js';
import { validateBareModelId } from '@automaker/types';
import { validateApiKey } from '../lib/auth-utils.js';
import { getEffectivePermissions } from '../services/cursor-config-service.js';
import { getEffectivePermissions, detectProfile } from '../services/cursor-config-service.js';
import {
type CursorStreamEvent,
type CursorSystemEvent,
@@ -69,6 +69,7 @@ interface CursorToolHandler<TArgs = unknown, TResult = unknown> {
* Registry of Cursor tool handlers
* Each handler knows how to normalize its specific tool call type
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- handler registry stores heterogeneous tool type parameters
const CURSOR_TOOL_HANDLERS: Record<string, CursorToolHandler<any, any>> = {
readToolCall: {
name: 'Read',
@@ -449,6 +450,11 @@ export class CursorProvider extends CliProvider {
cliArgs.push('--model', model);
}
// Resume an existing chat when a provider session ID is available
if (options.sdkSessionId) {
cliArgs.push('--resume', options.sdkSessionId);
}
// Use '-' to indicate reading prompt from stdin
cliArgs.push('-');
@@ -556,10 +562,14 @@ export class CursorProvider extends CliProvider {
const resultEvent = cursorEvent as CursorResultEvent;
if (resultEvent.is_error) {
const errorText = resultEvent.error || resultEvent.result || '';
const enrichedError =
errorText ||
`Cursor agent failed (duration: ${resultEvent.duration_ms}ms, subtype: ${resultEvent.subtype}, session: ${resultEvent.session_id ?? 'none'})`;
return {
type: 'error',
session_id: resultEvent.session_id,
error: resultEvent.error || resultEvent.result || 'Unknown error',
error: enrichedError,
};
}
@@ -833,9 +843,10 @@ export class CursorProvider extends CliProvider {
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
this.ensureCliDetected();
// Validate that model doesn't have a provider prefix
// Validate that model doesn't have a provider prefix (except cursor- which should already be stripped)
// AgentService should strip prefixes before passing to providers
validateBareModelId(options.model, 'CursorProvider');
// Note: Cursor's Gemini models (e.g., "gemini-3-pro") legitimately start with "gemini-"
validateBareModelId(options.model, 'CursorProvider', 'cursor');
if (!this.cliPath) {
throw this.createError(
@@ -877,8 +888,12 @@ export class CursorProvider extends CliProvider {
logger.debug(`CursorProvider.executeQuery called with model: "${options.model}"`);
// Get effective permissions for this project
// Get effective permissions for this project and detect the active profile
const effectivePermissions = await getEffectivePermissions(options.cwd || process.cwd());
const activeProfile = detectProfile(effectivePermissions);
logger.debug(
`Active permission profile: ${activeProfile ?? 'none'}, permissions: ${JSON.stringify(effectivePermissions)}`
);
// Debug: log raw events when AUTOMAKER_DEBUG_RAW_OUTPUT is enabled
const debugRawEvents =

View File

@@ -20,12 +20,11 @@ import type {
ProviderMessage,
InstallationStatus,
ModelDefinition,
ContentBlock,
} from './types.js';
import { validateBareModelId } from '@automaker/types';
import { GEMINI_MODEL_MAP, type GeminiAuthStatus } from '@automaker/types';
import { createLogger, isAbortError } from '@automaker/utils';
import { spawnJSONLProcess } from '@automaker/platform';
import { spawnJSONLProcess, type SubprocessOptions } from '@automaker/platform';
import { normalizeTodos } from './tool-normalization.js';
// Create logger for this module
@@ -264,6 +263,14 @@ export class GeminiProvider extends CliProvider {
// Use explicit approval-mode for clearer semantics
cliArgs.push('--approval-mode', 'yolo');
// Force headless (non-interactive) mode with --prompt flag.
// The actual prompt content is passed via stdin (see buildSubprocessOptions()),
// but we MUST include -p to trigger headless mode. Without it, Gemini CLI
// starts in interactive mode which adds significant startup overhead
// (interactive REPL setup, extra context loading, etc.).
// Per Gemini CLI docs: stdin content is "appended to" the -p value.
cliArgs.push('--prompt', '');
// Explicitly include the working directory in allowed workspace directories
// This ensures Gemini CLI allows file operations in the project directory,
// even if it has a different workspace cached from a previous session
@@ -271,13 +278,15 @@ export class GeminiProvider extends CliProvider {
cliArgs.push('--include-directories', options.cwd);
}
// Resume an existing Gemini session when one is available
if (options.sdkSessionId) {
cliArgs.push('--resume', options.sdkSessionId);
}
// Note: Gemini CLI doesn't have a --thinking-level flag.
// Thinking capabilities are determined by the model selection (e.g., gemini-2.5-pro).
// The model handles thinking internally based on the task complexity.
// The prompt will be passed as the last positional argument
// We'll append it in executeQuery after extracting the text
return cliArgs;
}
@@ -372,10 +381,13 @@ export class GeminiProvider extends CliProvider {
const resultEvent = geminiEvent as GeminiResultEvent;
if (resultEvent.status === 'error') {
const enrichedError =
resultEvent.error ||
`Gemini agent failed (duration: ${resultEvent.stats?.duration_ms ?? 'unknown'}ms, session: ${resultEvent.session_id ?? 'none'})`;
return {
type: 'error',
session_id: resultEvent.session_id,
error: resultEvent.error || 'Unknown error',
error: enrichedError,
};
}
@@ -392,10 +404,12 @@ export class GeminiProvider extends CliProvider {
case 'error': {
const errorEvent = geminiEvent as GeminiResultEvent;
const enrichedError =
errorEvent.error || `Gemini agent failed (session: ${errorEvent.session_id ?? 'none'})`;
return {
type: 'error',
session_id: errorEvent.session_id,
error: errorEvent.error || 'Unknown error',
error: enrichedError,
};
}
@@ -409,6 +423,32 @@ export class GeminiProvider extends CliProvider {
// CliProvider Overrides
// ==========================================================================
/**
* Build subprocess options with stdin data for prompt and speed-optimized env vars.
*
* Passes the prompt via stdin instead of --prompt CLI arg to:
* - Avoid shell argument size limits with large prompts (system prompt + context)
* - Avoid shell escaping issues with special characters in prompts
* - Match the pattern used by Cursor, OpenCode, and Codex providers
*
* Also injects environment variables to reduce Gemini CLI startup overhead:
* - GEMINI_TELEMETRY_ENABLED=false: Disables OpenTelemetry collection
*/
protected buildSubprocessOptions(options: ExecuteOptions, cliArgs: string[]): SubprocessOptions {
const subprocessOptions = super.buildSubprocessOptions(options, cliArgs);
// Pass prompt via stdin to avoid shell interpretation of special characters
// and shell argument size limits with large system prompts + context files
subprocessOptions.stdinData = this.extractPromptText(options);
// Disable telemetry to reduce startup overhead
if (subprocessOptions.env) {
subprocessOptions.env['GEMINI_TELEMETRY_ENABLED'] = 'false';
}
return subprocessOptions;
}
/**
* Override error mapping for Gemini-specific error codes
*/
@@ -506,8 +546,8 @@ export class GeminiProvider extends CliProvider {
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
this.ensureCliDetected();
// Validate that model doesn't have a provider prefix
validateBareModelId(options.model, 'GeminiProvider');
// Validate that model doesn't have a provider prefix (except gemini- which should already be stripped)
validateBareModelId(options.model, 'GeminiProvider', 'gemini');
if (!this.cliPath) {
throw this.createError(
@@ -518,14 +558,21 @@ export class GeminiProvider extends CliProvider {
);
}
// Extract prompt text to pass as positional argument
const promptText = this.extractPromptText(options);
// Ensure .geminiignore exists in the working directory to prevent Gemini CLI
// from scanning .git and node_modules directories during startup. This reduces
// startup time significantly (reported: 35s → 11s) by skipping large directories
// that Gemini CLI would otherwise traverse for context discovery.
await this.ensureGeminiIgnore(options.cwd || process.cwd());
// Build CLI args and append the prompt as the last positional argument
const cliArgs = this.buildCliArgs(options);
cliArgs.push(promptText); // Gemini CLI uses positional args for the prompt
// Embed system prompt into the user prompt so Gemini CLI receives
// project context (CLAUDE.md, CODE_QUALITY.md, etc.) that would
// otherwise be silently dropped since Gemini CLI has no --system-prompt flag.
const effectiveOptions = this.embedSystemPromptIntoPrompt(options);
const subprocessOptions = this.buildSubprocessOptions(options, cliArgs);
// Build CLI args for headless execution.
const cliArgs = this.buildCliArgs(effectiveOptions);
const subprocessOptions = this.buildSubprocessOptions(effectiveOptions, cliArgs);
let sessionId: string | undefined;
@@ -578,6 +625,49 @@ export class GeminiProvider extends CliProvider {
// Gemini-Specific Methods
// ==========================================================================
/**
* Ensure a .geminiignore file exists in the working directory.
*
* Gemini CLI scans the working directory for context discovery during startup.
* Excluding .git and node_modules dramatically reduces startup time by preventing
* traversal of large directories (reported improvement: 35s → 11s).
*
* Only creates the file if it doesn't already exist to avoid overwriting user config.
*/
private async ensureGeminiIgnore(cwd: string): Promise<void> {
const ignorePath = path.join(cwd, '.geminiignore');
const content = [
'# Auto-generated by Automaker to speed up Gemini CLI startup',
'# Prevents Gemini CLI from scanning large directories during context discovery',
'.git',
'node_modules',
'dist',
'build',
'.next',
'.nuxt',
'coverage',
'.automaker',
'.worktrees',
'.vscode',
'.idea',
'*.lock',
'',
].join('\n');
try {
// Use 'wx' flag for atomic creation - fails if file exists (EEXIST)
await fs.writeFile(ignorePath, content, { encoding: 'utf-8', flag: 'wx' });
logger.debug(`Created .geminiignore at ${ignorePath}`);
} catch (writeError) {
// EEXIST means file already exists - that's fine, preserve user's file
if ((writeError as NodeJS.ErrnoException).code === 'EEXIST') {
logger.debug(`.geminiignore already exists at ${ignorePath}, preserving existing file`);
return;
}
// Non-fatal: startup will just be slower without the ignore file
logger.debug(`Failed to create .geminiignore: ${writeError}`);
}
}
/**
* Create a GeminiError with details
*/

View File

@@ -0,0 +1,53 @@
/**
* Mock Provider - No-op AI provider for E2E and CI testing
*
* When AUTOMAKER_MOCK_AGENT=true, the server uses this provider instead of
* real backends (Claude, Codex, etc.) so tests never call external APIs.
*/
import type { ExecuteOptions } from '@automaker/types';
import { BaseProvider } from './base-provider.js';
import type { ProviderMessage, InstallationStatus, ModelDefinition } from './types.js';
const MOCK_TEXT = 'Mock agent output for testing.';
export class MockProvider extends BaseProvider {
getName(): string {
return 'mock';
}
async *executeQuery(_options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
yield {
type: 'assistant',
message: {
role: 'assistant',
content: [{ type: 'text', text: MOCK_TEXT }],
},
};
yield {
type: 'result',
subtype: 'success',
};
}
async detectInstallation(): Promise<InstallationStatus> {
return {
installed: true,
method: 'sdk',
hasApiKey: true,
authenticated: true,
};
}
getAvailableModels(): ModelDefinition[] {
return [
{
id: 'mock-model',
name: 'Mock Model',
modelString: 'mock-model',
provider: 'mock',
description: 'Mock model for testing',
},
];
}
}

View File

@@ -192,6 +192,28 @@ export interface OpenCodeToolErrorEvent extends OpenCodeBaseEvent {
part?: OpenCodePart & { error: string };
}
/**
* Tool use event - The actual format emitted by OpenCode CLI when a tool is invoked.
* Contains the tool name, call ID, and the complete state (input, output, status).
* Note: OpenCode CLI emits 'tool_use' (not 'tool_call') as the event type.
*/
export interface OpenCodeToolUseEvent extends OpenCodeBaseEvent {
type: 'tool_use';
part: OpenCodePart & {
type: 'tool';
callID?: string;
tool?: string;
state?: {
status?: string;
input?: unknown;
output?: string;
title?: string;
metadata?: unknown;
time?: { start: number; end: number };
};
};
}
/**
* Union type of all OpenCode stream events
*/
@@ -200,6 +222,7 @@ export type OpenCodeStreamEvent =
| OpenCodeStepStartEvent
| OpenCodeStepFinishEvent
| OpenCodeToolCallEvent
| OpenCodeToolUseEvent
| OpenCodeToolResultEvent
| OpenCodeErrorEvent
| OpenCodeToolErrorEvent;
@@ -311,8 +334,8 @@ export class OpencodeProvider extends CliProvider {
* Arguments built:
* - 'run' subcommand for executing queries
* - '--format', 'json' for JSONL streaming output
* - '-c', '<cwd>' for working directory (using opencode's -c flag)
* - '--model', '<model>' for model selection (if specified)
* - '--session', '<id>' for continuing an existing session (if sdkSessionId is set)
*
* The prompt is passed via stdin (piped) to avoid shell escaping issues.
* OpenCode CLI automatically reads from stdin when input is piped.
@@ -326,6 +349,14 @@ export class OpencodeProvider extends CliProvider {
// Add JSON output format for JSONL parsing (not 'stream-json')
args.push('--format', 'json');
// Handle session resumption for conversation continuity.
// The opencode CLI supports `--session <id>` to continue an existing session.
// The sdkSessionId is captured from the sessionID field in previous stream events
// and persisted by AgentService for use in follow-up messages.
if (options.sdkSessionId) {
args.push('--session', options.sdkSessionId);
}
// Handle model selection
// Convert canonical prefix format (opencode-xxx) to CLI slash format (opencode/xxx)
// OpenCode CLI expects provider/model format (e.g., 'opencode/big-model')
@@ -398,15 +429,225 @@ export class OpencodeProvider extends CliProvider {
return subprocessOptions;
}
/**
* Check if an error message indicates a session-not-found condition.
*
* Centralizes the pattern matching for session errors to avoid duplication.
* Strips ANSI escape codes first since opencode CLI uses colored stderr output
* (e.g. "\x1b[91m\x1b[1mError: \x1b[0mSession not found").
*
* IMPORTANT: Patterns must be specific enough to avoid false positives.
* Generic patterns like "notfounderror" or "resource not found" match
* non-session errors (e.g. "ProviderModelNotFoundError") which would
* trigger unnecessary retries that fail identically, producing confusing
* error messages like "OpenCode session could not be created".
*
* @param errorText - Raw error text (may contain ANSI codes)
* @returns true if the error indicates the session was not found
*/
private static isSessionNotFoundError(errorText: string): boolean {
const cleaned = OpencodeProvider.stripAnsiCodes(errorText).toLowerCase();
// Explicit session-related phrases — high confidence
if (
cleaned.includes('session not found') ||
cleaned.includes('session does not exist') ||
cleaned.includes('invalid session') ||
cleaned.includes('session expired') ||
cleaned.includes('no such session')
) {
return true;
}
// Generic "NotFoundError" / "resource not found" are only session errors
// when the message also references a session path or session ID.
// Without this guard, errors like "ProviderModelNotFoundError" or
// "Resource not found: /path/to/config.json" would false-positive.
if (cleaned.includes('notfounderror') || cleaned.includes('resource not found')) {
return cleaned.includes('/session/') || /\bsession\b/.test(cleaned);
}
return false;
}
/**
* Strip ANSI escape codes from a string.
*
* The OpenCode CLI uses colored stderr output (e.g. "\x1b[91m\x1b[1mError: \x1b[0m").
* These escape codes render as garbled text like "[91m[1mError: [0m" in the UI
* when passed through as-is. This utility removes them so error messages are
* clean and human-readable.
*/
private static stripAnsiCodes(text: string): string {
return text.replace(/\x1b\[[0-9;]*m/g, '');
}
/**
* Clean a CLI error message for display.
*
* Strips ANSI escape codes AND removes the redundant "Error: " prefix that
* the OpenCode CLI prepends to error messages in its colored stderr output
* (e.g. "\x1b[91m\x1b[1mError: \x1b[0mSession not found" → "Session not found").
*
* Without this, consumers that wrap the message in their own "Error: " prefix
* (like AgentService or AgentExecutor) produce garbled double-prefixed output:
* "Error: Error: Session not found".
*/
private static cleanErrorMessage(text: string): string {
let cleaned = OpencodeProvider.stripAnsiCodes(text).trim();
// Remove leading "Error: " prefix (case-insensitive) if present.
// The CLI formats errors as: \x1b[91m\x1b[1mError: \x1b[0m<actual message>
// After ANSI stripping this becomes: "Error: <actual message>"
cleaned = cleaned.replace(/^Error:\s*/i, '').trim();
return cleaned || text;
}
/**
* Execute a query with automatic session resumption fallback.
*
* When a sdkSessionId is provided, the CLI receives `--session <id>`.
* If the session no longer exists on disk the CLI will fail with a
* "NotFoundError" / "Resource not found" / "Session not found" error.
*
* The opencode CLI writes this to **stderr** and exits non-zero.
* `spawnJSONLProcess` collects stderr and **yields** it as
* `{ type: 'error', error: <stderrText> }` — it is NOT thrown.
* After `normalizeEvent`, the error becomes a yielded `ProviderMessage`
* with `type: 'error'`. A simple try/catch therefore cannot intercept it.
*
* This override iterates the parent stream, intercepts yielded error
* messages that match the session-not-found pattern, and retries the
* entire query WITHOUT the `--session` flag so a fresh session is started.
*
* Session-not-found retry is ONLY attempted when `sdkSessionId` is set.
* Without the `--session` flag the CLI always creates a fresh session, so
* retrying without it would be identical to the first attempt and would
* fail the same way — producing a confusing "session could not be created"
* message for what is actually a different error (model not found, auth
* failure, etc.).
*
* All error messages (session or not) are cleaned of ANSI codes and the
* CLI's redundant "Error: " prefix before being yielded to consumers.
*
* After a successful retry, the consumer (AgentService) will receive a new
* session_id from the fresh stream events, which it persists to metadata —
* replacing the stale sdkSessionId and preventing repeated failures.
*/
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
// When no sdkSessionId is set, there is nothing to "retry without" — just
// stream normally and clean error messages as they pass through.
if (!options.sdkSessionId) {
for await (const msg of super.executeQuery(options)) {
// Clean error messages so consumers don't get ANSI or double "Error:" prefix
if (msg.type === 'error' && msg.error && typeof msg.error === 'string') {
msg.error = OpencodeProvider.cleanErrorMessage(msg.error);
}
yield msg;
}
return;
}
// sdkSessionId IS set — the CLI will receive `--session <id>`.
// If that session no longer exists, intercept the error and retry fresh.
//
// To avoid buffering the entire stream in memory for long-lived sessions,
// we only buffer an initial window of messages until we observe a healthy
// (non-error) message. Once a healthy message is seen, we flush the buffer
// and switch to direct passthrough, while still watching for session errors
// via isSessionNotFoundError on any subsequent error messages.
const buffered: ProviderMessage[] = [];
let sessionError = false;
let seenHealthyMessage = false;
try {
for await (const msg of super.executeQuery(options)) {
if (msg.type === 'error') {
const errorText = msg.error || '';
if (OpencodeProvider.isSessionNotFoundError(errorText)) {
sessionError = true;
opencodeLogger.info(
`OpenCode session error detected (session "${options.sdkSessionId}") ` +
`— retrying without --session to start fresh`
);
break; // stop consuming the failed stream
}
// Non-session error — clean it
if (msg.error && typeof msg.error === 'string') {
msg.error = OpencodeProvider.cleanErrorMessage(msg.error);
}
} else {
// A non-error message is a healthy signal — stop buffering after this
seenHealthyMessage = true;
}
if (seenHealthyMessage && buffered.length > 0) {
// Flush the pre-healthy buffer first, then switch to passthrough
for (const bufferedMsg of buffered) {
yield bufferedMsg;
}
buffered.length = 0;
}
if (seenHealthyMessage) {
// Passthrough mode — yield directly without buffering
yield msg;
} else {
// Still in initial window — buffer until we see a healthy message
buffered.push(msg);
}
}
} catch (error) {
// Also handle thrown exceptions (e.g. from mapError in cli-provider)
const errMsg = error instanceof Error ? error.message : String(error);
if (OpencodeProvider.isSessionNotFoundError(errMsg)) {
sessionError = true;
opencodeLogger.info(
`OpenCode session error detected (thrown, session "${options.sdkSessionId}") ` +
`— retrying without --session to start fresh`
);
} else {
throw error;
}
}
if (sessionError) {
// Retry the entire query without the stale session ID.
const retryOptions = { ...options, sdkSessionId: undefined };
opencodeLogger.info('Retrying OpenCode query without --session flag...');
// Stream the retry directly to the consumer.
// If the retry also fails, it's a genuine error (not session-related)
// and should be surfaced as-is rather than masked with a misleading
// "session could not be created" message.
for await (const retryMsg of super.executeQuery(retryOptions)) {
if (retryMsg.type === 'error' && retryMsg.error && typeof retryMsg.error === 'string') {
retryMsg.error = OpencodeProvider.cleanErrorMessage(retryMsg.error);
}
yield retryMsg;
}
} else if (buffered.length > 0) {
// No session error and still have buffered messages (stream ended before
// any healthy message was observed) — flush them to the consumer
for (const msg of buffered) {
yield msg;
}
}
// If seenHealthyMessage is true, all messages have already been yielded
// directly in passthrough mode — nothing left to flush.
}
/**
* Normalize a raw CLI event to ProviderMessage format
*
* Maps OpenCode event types to the standard ProviderMessage structure:
* - text -> type: 'assistant', content with type: 'text'
* - step_start -> null (informational, no message needed)
* - step_finish with reason 'stop' -> type: 'result', subtype: 'success'
* - step_finish with reason 'stop'/'end_turn' -> type: 'result', subtype: 'success'
* - step_finish with reason 'tool-calls' -> null (intermediate step, not final)
* - step_finish with error -> type: 'error'
* - tool_call -> type: 'assistant', content with type: 'tool_use'
* - tool_use -> type: 'assistant', content with type: 'tool_use' (OpenCode CLI format)
* - tool_call -> type: 'assistant', content with type: 'tool_use' (legacy format)
* - tool_result -> type: 'assistant', content with type: 'tool_result'
* - error -> type: 'error'
*
@@ -459,7 +700,7 @@ export class OpencodeProvider extends CliProvider {
return {
type: 'error',
session_id: finishEvent.sessionID,
error: finishEvent.part.error,
error: OpencodeProvider.cleanErrorMessage(finishEvent.part.error),
};
}
@@ -468,15 +709,40 @@ export class OpencodeProvider extends CliProvider {
return {
type: 'error',
session_id: finishEvent.sessionID,
error: 'Step execution failed',
error: OpencodeProvider.cleanErrorMessage('Step execution failed'),
};
}
// Successful completion (reason: 'stop' or 'end_turn')
// Intermediate step completion (reason: 'tool-calls') — the agent loop
// is continuing because the model requested tool calls. Skip these so
// consumers don't mistake them for final results.
if (finishEvent.part?.reason === 'tool-calls') {
return null;
}
// Only treat an explicit allowlist of reasons as true success.
// Reasons like 'length' (context-window truncation) or 'content-filter'
// indicate the model stopped abnormally and must not be surfaced as
// successful completions.
const SUCCESS_REASONS = new Set(['stop', 'end_turn']);
const reason = finishEvent.part?.reason;
if (reason === undefined || SUCCESS_REASONS.has(reason)) {
// Final completion (reason: 'stop', 'end_turn', or unset)
return {
type: 'result',
subtype: 'success',
session_id: finishEvent.sessionID,
result: (finishEvent.part as OpenCodePart & { result?: string })?.result,
};
}
// Non-success, non-tool-calls reason (e.g. 'length', 'content-filter')
return {
type: 'result',
subtype: 'success',
subtype: 'error',
session_id: finishEvent.sessionID,
error: `Step finished with non-success reason: ${reason}`,
result: (finishEvent.part as OpenCodePart & { result?: string })?.result,
};
}
@@ -484,8 +750,10 @@ export class OpencodeProvider extends CliProvider {
case 'tool_error': {
const toolErrorEvent = openCodeEvent as OpenCodeBaseEvent;
// Extract error message from part.error
const errorMessage = toolErrorEvent.part?.error || 'Tool execution failed';
// Extract error message from part.error and clean ANSI codes
const errorMessage = OpencodeProvider.cleanErrorMessage(
toolErrorEvent.part?.error || 'Tool execution failed'
);
return {
type: 'error',
@@ -494,6 +762,45 @@ export class OpencodeProvider extends CliProvider {
};
}
// OpenCode CLI emits 'tool_use' events (not 'tool_call') when the model invokes a tool.
// The event format includes the tool name, call ID, and state with input/output.
// Handle both 'tool_use' (actual CLI format) and 'tool_call' (legacy/alternative) for robustness.
case 'tool_use': {
const toolUseEvent = openCodeEvent as OpenCodeToolUseEvent;
const part = toolUseEvent.part;
// Generate a tool use ID if not provided
const toolUseId = part?.callID || part?.call_id || generateToolUseId();
const toolName = part?.tool || part?.name || 'unknown';
const content: ContentBlock[] = [
{
type: 'tool_use',
name: toolName,
tool_use_id: toolUseId,
input: part?.state?.input || part?.args,
},
];
// If the tool has already completed (state.status === 'completed'), also emit the result
if (part?.state?.status === 'completed' && part?.state?.output) {
content.push({
type: 'tool_result',
tool_use_id: toolUseId,
content: part.state.output,
});
}
return {
type: 'assistant',
session_id: toolUseEvent.sessionID,
message: {
role: 'assistant',
content,
},
};
}
case 'tool_call': {
const toolEvent = openCodeEvent as OpenCodeToolCallEvent;
@@ -560,6 +867,13 @@ export class OpencodeProvider extends CliProvider {
errorMessage = errorEvent.part.error;
}
// Clean error messages: strip ANSI escape codes AND the redundant "Error: "
// prefix the CLI adds. The OpenCode CLI outputs colored stderr like:
// \x1b[91m\x1b[1mError: \x1b[0mSession not found
// Without cleaning, consumers that wrap in their own "Error: " prefix
// produce "Error: Error: Session not found".
errorMessage = OpencodeProvider.cleanErrorMessage(errorMessage);
return {
type: 'error',
session_id: errorEvent.sessionID,
@@ -623,9 +937,9 @@ export class OpencodeProvider extends CliProvider {
default: true,
},
{
id: 'opencode/glm-4.7-free',
name: 'GLM 4.7 Free',
modelString: 'opencode/glm-4.7-free',
id: 'opencode/glm-5-free',
name: 'GLM 5 Free',
modelString: 'opencode/glm-5-free',
provider: 'opencode',
description: 'OpenCode free tier GLM model',
supportsTools: true,
@@ -643,19 +957,19 @@ export class OpencodeProvider extends CliProvider {
tier: 'basic',
},
{
id: 'opencode/grok-code',
name: 'Grok Code (Free)',
modelString: 'opencode/grok-code',
id: 'opencode/kimi-k2.5-free',
name: 'Kimi K2.5 Free',
modelString: 'opencode/kimi-k2.5-free',
provider: 'opencode',
description: 'OpenCode free tier Grok model for coding',
description: 'OpenCode free tier Kimi model for coding',
supportsTools: true,
supportsVision: false,
tier: 'basic',
},
{
id: 'opencode/minimax-m2.1-free',
name: 'MiniMax M2.1 Free',
modelString: 'opencode/minimax-m2.1-free',
id: 'opencode/minimax-m2.5-free',
name: 'MiniMax M2.5 Free',
modelString: 'opencode/minimax-m2.5-free',
provider: 'opencode',
description: 'OpenCode free tier MiniMax model',
supportsTools: true,
@@ -777,7 +1091,7 @@ export class OpencodeProvider extends CliProvider {
*
* OpenCode CLI output format (one model per line):
* opencode/big-pickle
* opencode/glm-4.7-free
* opencode/glm-5-free
* anthropic/claude-3-5-haiku-20241022
* github-copilot/claude-3.5-sonnet
* ...
@@ -875,8 +1189,26 @@ export class OpencodeProvider extends CliProvider {
* Format a display name for a model
*/
private formatModelDisplayName(model: OpenCodeModelInfo): string {
// Extract the last path segment for nested model IDs
// e.g., "arcee-ai/trinity-large-preview:free" → "trinity-large-preview:free"
let rawName = model.name;
if (rawName.includes('/')) {
rawName = rawName.split('/').pop()!;
}
// Strip tier/pricing suffixes like ":free", ":extended"
const colonIdx = rawName.indexOf(':');
let suffix = '';
if (colonIdx !== -1) {
const tierPart = rawName.slice(colonIdx + 1);
if (/^(free|extended|beta|preview)$/i.test(tierPart)) {
suffix = ` (${tierPart.charAt(0).toUpperCase() + tierPart.slice(1)})`;
}
rawName = rawName.slice(0, colonIdx);
}
// Capitalize and format the model name
const formattedName = model.name
const formattedName = rawName
.split('-')
.map((part) => {
// Handle version numbers like "4-5" -> "4.5"
@@ -904,7 +1236,7 @@ export class OpencodeProvider extends CliProvider {
};
const providerDisplay = providerNames[model.provider] || model.provider;
return `${formattedName} (${providerDisplay})`;
return `${formattedName}${suffix} (${providerDisplay})`;
}
/**

View File

@@ -67,6 +67,16 @@ export function registerProvider(name: string, registration: ProviderRegistratio
providerRegistry.set(name.toLowerCase(), registration);
}
/** Cached mock provider instance when AUTOMAKER_MOCK_AGENT is set (E2E/CI). */
let mockProviderInstance: BaseProvider | null = null;
function getMockProvider(): BaseProvider {
if (!mockProviderInstance) {
mockProviderInstance = new MockProvider();
}
return mockProviderInstance;
}
export class ProviderFactory {
/**
* Determine which provider to use for a given model
@@ -75,6 +85,9 @@ export class ProviderFactory {
* @returns Provider name (ModelProvider type)
*/
static getProviderNameForModel(model: string): ModelProvider {
if (process.env.AUTOMAKER_MOCK_AGENT === 'true') {
return 'claude' as ModelProvider; // Name only; getProviderForModel returns MockProvider
}
const lowerModel = model.toLowerCase();
// Get all registered providers sorted by priority (descending)
@@ -103,7 +116,7 @@ export class ProviderFactory {
/**
* Get the appropriate provider for a given model ID
*
* @param modelId Model identifier (e.g., "claude-opus-4-5-20251101", "cursor-gpt-4o", "cursor-auto")
* @param modelId Model identifier (e.g., "claude-opus-4-6", "cursor-gpt-4o", "cursor-auto")
* @param options Optional settings
* @param options.throwOnDisconnected Throw error if provider is disconnected (default: true)
* @returns Provider instance for the model
@@ -113,6 +126,9 @@ export class ProviderFactory {
modelId: string,
options: { throwOnDisconnected?: boolean } = {}
): BaseProvider {
if (process.env.AUTOMAKER_MOCK_AGENT === 'true') {
return getMockProvider();
}
const { throwOnDisconnected = true } = options;
const providerName = this.getProviderForModelName(modelId);
@@ -142,6 +158,9 @@ export class ProviderFactory {
* Get the provider name for a given model ID (without creating provider instance)
*/
static getProviderForModelName(modelId: string): string {
if (process.env.AUTOMAKER_MOCK_AGENT === 'true') {
return 'claude';
}
const lowerModel = modelId.toLowerCase();
// Get all registered providers sorted by priority (descending)
@@ -272,6 +291,7 @@ export class ProviderFactory {
// =============================================================================
// Import providers for registration side-effects
import { MockProvider } from './mock-provider.js';
import { ClaudeProvider } from './claude-provider.js';
import { CursorProvider } from './cursor-provider.js';
import { CodexProvider } from './codex-provider.js';

View File

@@ -16,8 +16,6 @@
import { ProviderFactory } from './provider-factory.js';
import type {
ProviderMessage,
ContentBlock,
ThinkingLevel,
ReasoningEffort,
ClaudeApiProfile,
@@ -96,7 +94,7 @@ export interface StreamingQueryOptions extends SimpleQueryOptions {
/**
* Default model to use when none specified
*/
const DEFAULT_MODEL = 'claude-sonnet-4-20250514';
const DEFAULT_MODEL = 'claude-sonnet-4-6';
/**
* Execute a simple query and return the text result

View File

@@ -16,7 +16,7 @@ export function createHistoryHandler(agentService: AgentService) {
return;
}
const result = agentService.getHistory(sessionId);
const result = await agentService.getHistory(sessionId);
res.json(result);
} catch (error) {
logError(error, 'Get history failed');

View File

@@ -19,7 +19,7 @@ export function createQueueListHandler(agentService: AgentService) {
return;
}
const result = agentService.getQueue(sessionId);
const result = await agentService.getQueue(sessionId);
res.json(result);
} catch (error) {
logError(error, 'List queue failed');

View File

@@ -53,7 +53,15 @@ export function createSendHandler(agentService: AgentService) {
thinkingLevel,
})
.catch((error) => {
logger.error('Background error in sendMessage():', error);
const errorMsg = (error as Error).message || 'Unknown error';
logger.error(`Background error in sendMessage() for session ${sessionId}:`, errorMsg);
// Emit error via WebSocket so the UI is notified even though
// the HTTP response already returned 200. This is critical for
// session-not-found errors where sendMessage() throws before it
// can emit its own error event (no in-memory session to emit from).
agentService.emitSessionError(sessionId, errorMsg);
logError(error, 'Send message failed (background)');
});

View File

@@ -6,7 +6,7 @@ import type { Request, Response } from 'express';
import { AgentService } from '../../../services/agent-service.js';
import { createLogger } from '@automaker/utils';
import { getErrorMessage, logError } from '../common.js';
const logger = createLogger('Agent');
const _logger = createLogger('Agent');
export function createStartHandler(agentService: AgentService) {
return async (req: Request, res: Response): Promise<void> => {

View File

@@ -128,7 +128,7 @@ export function logAuthStatus(context: string): void {
*/
export function logError(error: unknown, context: string): void {
logger.error(`${context}:`);
logger.error('Error name:', (error as any)?.name);
logger.error('Error name:', (error as Error)?.name);
logger.error('Error message:', (error as Error)?.message);
logger.error('Error stack:', (error as Error)?.stack);
logger.error('Full error object:', JSON.stringify(error, Object.getOwnPropertyNames(error), 2));

View File

@@ -30,7 +30,7 @@ const DEFAULT_MAX_FEATURES = 50;
* Timeout for Codex models when generating features (5 minutes).
* Codex models are slower and need more time to generate 50+ features.
*/
const CODEX_FEATURE_GENERATION_TIMEOUT_MS = 300000; // 5 minutes
const _CODEX_FEATURE_GENERATION_TIMEOUT_MS = 300000; // 5 minutes
/**
* Type for extracted features JSON response
@@ -323,7 +323,7 @@ Your entire response should be valid JSON starting with { and ending with }. No
}
}
await parseAndCreateFeatures(projectPath, contentForParsing, events);
await parseAndCreateFeatures(projectPath, contentForParsing, events, settingsService);
logger.debug('========== generateFeaturesFromSpec() completed ==========');
}

View File

@@ -9,13 +9,16 @@ import { createLogger, atomicWriteJson, DEFAULT_BACKUP_COUNT } from '@automaker/
import { getFeaturesDir } from '@automaker/platform';
import { extractJsonWithArray } from '../../lib/json-extractor.js';
import { getNotificationService } from '../../services/notification-service.js';
import type { SettingsService } from '../../services/settings-service.js';
import { resolvePhaseModel } from '@automaker/model-resolver';
const logger = createLogger('SpecRegeneration');
export async function parseAndCreateFeatures(
projectPath: string,
content: string,
events: EventEmitter
events: EventEmitter,
settingsService?: SettingsService
): Promise<void> {
logger.info('========== parseAndCreateFeatures() started ==========');
logger.info(`Content length: ${content.length} chars`);
@@ -23,6 +26,37 @@ export async function parseAndCreateFeatures(
logger.info(content);
logger.info('========== END CONTENT ==========');
// Load default model and planning settings from settingsService
let defaultModel: string | undefined;
let defaultPlanningMode: string = 'skip';
let defaultRequirePlanApproval = false;
if (settingsService) {
try {
const globalSettings = await settingsService.getGlobalSettings();
const projectSettings = await settingsService.getProjectSettings(projectPath);
const defaultModelEntry =
projectSettings.defaultFeatureModel ?? globalSettings.defaultFeatureModel;
if (defaultModelEntry) {
const resolved = resolvePhaseModel(defaultModelEntry);
defaultModel = resolved.model;
}
defaultPlanningMode = globalSettings.defaultPlanningMode ?? 'skip';
defaultRequirePlanApproval = globalSettings.defaultRequirePlanApproval ?? false;
logger.info(
`[parseAndCreateFeatures] Using defaults: model=${defaultModel ?? 'none'}, planningMode=${defaultPlanningMode}, requirePlanApproval=${defaultRequirePlanApproval}`
);
} catch (settingsError) {
logger.warn(
'[parseAndCreateFeatures] Failed to load settings, using defaults:',
settingsError
);
}
}
try {
// Extract JSON from response using shared utility
logger.info('Extracting JSON from response using extractJsonWithArray...');
@@ -61,7 +95,7 @@ export async function parseAndCreateFeatures(
const featureDir = path.join(featuresDir, feature.id);
await secureFs.mkdir(featureDir, { recursive: true });
const featureData = {
const featureData: Record<string, unknown> = {
id: feature.id,
category: feature.category || 'Uncategorized',
title: feature.title,
@@ -70,10 +104,20 @@ export async function parseAndCreateFeatures(
priority: feature.priority || 2,
complexity: feature.complexity || 'moderate',
dependencies: feature.dependencies || [],
planningMode: defaultPlanningMode,
requirePlanApproval:
defaultPlanningMode === 'skip' || defaultPlanningMode === 'lite'
? false
: defaultRequirePlanApproval,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
// Apply default model if available from settings
if (defaultModel) {
featureData.model = defaultModel;
}
// Use atomic write with backup support for crash protection
await atomicWriteJson(path.join(featureDir, 'feature.json'), featureData, {
backupCount: DEFAULT_BACKUP_COUNT,

View File

@@ -29,7 +29,6 @@ import {
updateTechnologyStack,
updateRoadmapPhaseStatus,
type ImplementedFeature,
type RoadmapPhase,
} from '../../lib/xml-extractor.js';
import { getNotificationService } from '../../services/notification-service.js';

View File

@@ -1,11 +1,12 @@
/**
* Auto Mode routes - HTTP API for autonomous feature implementation
*
* Uses the AutoModeService for real feature execution with Claude Agent SDK
* Uses AutoModeServiceCompat which provides the old interface while
* delegating to GlobalAutoModeService and per-project facades.
*/
import { Router } from 'express';
import type { AutoModeService } from '../../services/auto-mode-service.js';
import type { AutoModeServiceCompat } from '../../services/auto-mode/index.js';
import { validatePathParams } from '../../middleware/validate-paths.js';
import { createStopFeatureHandler } from './routes/stop-feature.js';
import { createStatusHandler } from './routes/status.js';
@@ -20,8 +21,14 @@ import { createFollowUpFeatureHandler } from './routes/follow-up-feature.js';
import { createCommitFeatureHandler } from './routes/commit-feature.js';
import { createApprovePlanHandler } from './routes/approve-plan.js';
import { createResumeInterruptedHandler } from './routes/resume-interrupted.js';
import { createReconcileHandler } from './routes/reconcile.js';
export function createAutoModeRoutes(autoModeService: AutoModeService): Router {
/**
* Create auto-mode routes.
*
* @param autoModeService - AutoModeServiceCompat instance
*/
export function createAutoModeRoutes(autoModeService: AutoModeServiceCompat): Router {
const router = Router();
// Auto loop control routes
@@ -75,6 +82,11 @@ export function createAutoModeRoutes(autoModeService: AutoModeService): Router {
validatePathParams('projectPath'),
createResumeInterruptedHandler(autoModeService)
);
router.post(
'/reconcile',
validatePathParams('projectPath'),
createReconcileHandler(autoModeService)
);
return router;
}

View File

@@ -3,13 +3,13 @@
*/
import type { Request, Response } from 'express';
import type { AutoModeService } from '../../../services/auto-mode-service.js';
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
import { createLogger } from '@automaker/utils';
import { getErrorMessage, logError } from '../common.js';
const logger = createLogger('AutoMode');
export function createAnalyzeProjectHandler(autoModeService: AutoModeService) {
export function createAnalyzeProjectHandler(autoModeService: AutoModeServiceCompat) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath } = req.body as { projectPath: string };
@@ -19,10 +19,11 @@ export function createAnalyzeProjectHandler(autoModeService: AutoModeService) {
return;
}
// Start analysis in background
autoModeService.analyzeProject(projectPath).catch((error) => {
logger.error(`[AutoMode] Project analysis error:`, error);
});
// Kick off analysis in the background; attach a rejection handler so
// unhandled-promise warnings don't surface and errors are at least logged.
// Synchronous throws (e.g. "not implemented") still propagate here.
const analysisPromise = autoModeService.analyzeProject(projectPath);
analysisPromise.catch((err) => logError(err, 'Background analyzeProject failed'));
res.json({ success: true, message: 'Project analysis started' });
} catch (error) {

View File

@@ -3,13 +3,13 @@
*/
import type { Request, Response } from 'express';
import type { AutoModeService } from '../../../services/auto-mode-service.js';
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
import { createLogger } from '@automaker/utils';
import { getErrorMessage, logError } from '../common.js';
const logger = createLogger('AutoMode');
export function createApprovePlanHandler(autoModeService: AutoModeService) {
export function createApprovePlanHandler(autoModeService: AutoModeServiceCompat) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { featureId, approved, editedPlan, feedback, projectPath } = req.body as {
@@ -17,7 +17,7 @@ export function createApprovePlanHandler(autoModeService: AutoModeService) {
approved: boolean;
editedPlan?: string;
feedback?: string;
projectPath?: string;
projectPath: string;
};
if (!featureId) {
@@ -36,6 +36,14 @@ export function createApprovePlanHandler(autoModeService: AutoModeService) {
return;
}
if (!projectPath) {
res.status(400).json({
success: false,
error: 'projectPath is required',
});
return;
}
// Note: We no longer check hasPendingApproval here because resolvePlanApproval
// can handle recovery when pending approval is not in Map but feature has planSpec.status='generated'
// This supports cases where the server restarted while waiting for approval
@@ -48,11 +56,11 @@ export function createApprovePlanHandler(autoModeService: AutoModeService) {
// Resolve the pending approval (with recovery support)
const result = await autoModeService.resolvePlanApproval(
projectPath,
featureId,
approved,
editedPlan,
feedback,
projectPath
feedback
);
if (!result.success) {

View File

@@ -3,10 +3,10 @@
*/
import type { Request, Response } from 'express';
import type { AutoModeService } from '../../../services/auto-mode-service.js';
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
import { getErrorMessage, logError } from '../common.js';
export function createCommitFeatureHandler(autoModeService: AutoModeService) {
export function createCommitFeatureHandler(autoModeService: AutoModeServiceCompat) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, featureId, worktreePath } = req.body as {

View File

@@ -3,10 +3,10 @@
*/
import type { Request, Response } from 'express';
import type { AutoModeService } from '../../../services/auto-mode-service.js';
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
import { getErrorMessage, logError } from '../common.js';
export function createContextExistsHandler(autoModeService: AutoModeService) {
export function createContextExistsHandler(autoModeService: AutoModeServiceCompat) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, featureId } = req.body as {

View File

@@ -3,13 +3,13 @@
*/
import type { Request, Response } from 'express';
import type { AutoModeService } from '../../../services/auto-mode-service.js';
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
import { createLogger } from '@automaker/utils';
import { getErrorMessage, logError } from '../common.js';
const logger = createLogger('AutoMode');
export function createFollowUpFeatureHandler(autoModeService: AutoModeService) {
export function createFollowUpFeatureHandler(autoModeService: AutoModeServiceCompat) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, featureId, prompt, imagePaths, useWorktrees } = req.body as {
@@ -30,16 +30,12 @@ export function createFollowUpFeatureHandler(autoModeService: AutoModeService) {
// Start follow-up in background
// followUpFeature derives workDir from feature.branchName
// Default to false to match run-feature/resume-feature behavior.
// Worktrees should only be used when explicitly enabled by the user.
autoModeService
// Default to false to match run-feature/resume-feature behavior.
// Worktrees should only be used when explicitly enabled by the user.
.followUpFeature(projectPath, featureId, prompt, imagePaths, useWorktrees ?? false)
.catch((error) => {
logger.error(`[AutoMode] Follow up feature ${featureId} error:`, error);
})
.finally(() => {
// Release the starting slot when follow-up completes (success or error)
// Note: The feature should be in runningFeatures by this point
});
res.json({ success: true });

View File

@@ -0,0 +1,53 @@
/**
* Reconcile Feature States Handler
*
* On-demand endpoint to reconcile all feature states for a project.
* Resets features stuck in transient states (in_progress, interrupted, pipeline_*)
* back to resting states (ready/backlog) and emits events to update the UI.
*
* This is useful when:
* - The UI reconnects after a server restart
* - A client detects stale feature states
* - An admin wants to force-reset stuck features
*/
import type { Request, Response } from 'express';
import { createLogger } from '@automaker/utils';
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
const logger = createLogger('ReconcileFeatures');
interface ReconcileRequest {
projectPath: string;
}
export function createReconcileHandler(autoModeService: AutoModeServiceCompat) {
return async (req: Request, res: Response): Promise<void> => {
const { projectPath } = req.body as ReconcileRequest;
if (!projectPath) {
res.status(400).json({ error: 'Project path is required' });
return;
}
logger.info(`Reconciling feature states for ${projectPath}`);
try {
const reconciledCount = await autoModeService.reconcileFeatureStates(projectPath);
res.json({
success: true,
reconciledCount,
message:
reconciledCount > 0
? `Reconciled ${reconciledCount} feature(s)`
: 'No features needed reconciliation',
});
} catch (error) {
logger.error('Error reconciling feature states:', error);
res.status(500).json({
error: error instanceof Error ? error.message : 'Unknown error',
});
}
};
}

View File

@@ -3,13 +3,13 @@
*/
import type { Request, Response } from 'express';
import type { AutoModeService } from '../../../services/auto-mode-service.js';
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
import { createLogger } from '@automaker/utils';
import { getErrorMessage, logError } from '../common.js';
const logger = createLogger('AutoMode');
export function createResumeFeatureHandler(autoModeService: AutoModeService) {
export function createResumeFeatureHandler(autoModeService: AutoModeServiceCompat) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, featureId, useWorktrees } = req.body as {

View File

@@ -7,7 +7,7 @@
import type { Request, Response } from 'express';
import { createLogger } from '@automaker/utils';
import type { AutoModeService } from '../../../services/auto-mode-service.js';
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
const logger = createLogger('ResumeInterrupted');
@@ -15,7 +15,7 @@ interface ResumeInterruptedRequest {
projectPath: string;
}
export function createResumeInterruptedHandler(autoModeService: AutoModeService) {
export function createResumeInterruptedHandler(autoModeService: AutoModeServiceCompat) {
return async (req: Request, res: Response): Promise<void> => {
const { projectPath } = req.body as ResumeInterruptedRequest;
@@ -28,6 +28,7 @@ export function createResumeInterruptedHandler(autoModeService: AutoModeService)
try {
await autoModeService.resumeInterruptedFeatures(projectPath);
res.json({
success: true,
message: 'Resume check completed',

View File

@@ -3,13 +3,13 @@
*/
import type { Request, Response } from 'express';
import type { AutoModeService } from '../../../services/auto-mode-service.js';
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
import { createLogger } from '@automaker/utils';
import { getErrorMessage, logError } from '../common.js';
const logger = createLogger('AutoMode');
export function createRunFeatureHandler(autoModeService: AutoModeService) {
export function createRunFeatureHandler(autoModeService: AutoModeServiceCompat) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, featureId, useWorktrees } = req.body as {
@@ -26,23 +26,9 @@ export function createRunFeatureHandler(autoModeService: AutoModeService) {
return;
}
// Check per-worktree capacity before starting
const capacity = await autoModeService.checkWorktreeCapacity(projectPath, featureId);
if (!capacity.hasCapacity) {
const worktreeDesc = capacity.branchName
? `worktree "${capacity.branchName}"`
: 'main worktree';
res.status(429).json({
success: false,
error: `Agent limit reached for ${worktreeDesc} (${capacity.currentAgents}/${capacity.maxAgents}). Wait for running tasks to complete or increase the limit.`,
details: {
currentAgents: capacity.currentAgents,
maxAgents: capacity.maxAgents,
branchName: capacity.branchName,
},
});
return;
}
// Note: No concurrency limit check here. Manual feature starts always run
// immediately and bypass the concurrency limit. Their presence IS counted
// by the auto-loop coordinator when deciding whether to dispatch new auto-mode tasks.
// Start execution in background
// executeFeature derives workDir from feature.branchName
@@ -50,10 +36,6 @@ export function createRunFeatureHandler(autoModeService: AutoModeService) {
.executeFeature(projectPath, featureId, useWorktrees ?? false, false)
.catch((error) => {
logger.error(`Feature ${featureId} error:`, error);
})
.finally(() => {
// Release the starting slot when execution completes (success or error)
// Note: The feature should be in runningFeatures by this point
});
res.json({ success: true });

View File

@@ -3,13 +3,13 @@
*/
import type { Request, Response } from 'express';
import type { AutoModeService } from '../../../services/auto-mode-service.js';
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
import { createLogger } from '@automaker/utils';
import { getErrorMessage, logError } from '../common.js';
const logger = createLogger('AutoMode');
export function createStartHandler(autoModeService: AutoModeService) {
export function createStartHandler(autoModeService: AutoModeServiceCompat) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, branchName, maxConcurrency } = req.body as {

View File

@@ -6,10 +6,13 @@
*/
import type { Request, Response } from 'express';
import type { AutoModeService } from '../../../services/auto-mode-service.js';
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
import { getErrorMessage, logError } from '../common.js';
export function createStatusHandler(autoModeService: AutoModeService) {
/**
* Create status handler.
*/
export function createStatusHandler(autoModeService: AutoModeServiceCompat) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, branchName } = req.body as {
@@ -21,7 +24,8 @@ export function createStatusHandler(autoModeService: AutoModeService) {
if (projectPath) {
// Normalize branchName: undefined becomes null
const normalizedBranchName = branchName ?? null;
const projectStatus = autoModeService.getStatusForProject(
const projectStatus = await autoModeService.getStatusForProject(
projectPath,
normalizedBranchName
);
@@ -38,7 +42,7 @@ export function createStatusHandler(autoModeService: AutoModeService) {
return;
}
// Fall back to global status for backward compatibility
// Global status for backward compatibility
const status = autoModeService.getStatus();
const activeProjects = autoModeService.getActiveAutoLoopProjects();
const activeWorktrees = autoModeService.getActiveAutoLoopWorktrees();

View File

@@ -3,10 +3,10 @@
*/
import type { Request, Response } from 'express';
import type { AutoModeService } from '../../../services/auto-mode-service.js';
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
import { getErrorMessage, logError } from '../common.js';
export function createStopFeatureHandler(autoModeService: AutoModeService) {
export function createStopFeatureHandler(autoModeService: AutoModeServiceCompat) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { featureId } = req.body as { featureId: string };

View File

@@ -3,13 +3,13 @@
*/
import type { Request, Response } from 'express';
import type { AutoModeService } from '../../../services/auto-mode-service.js';
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
import { createLogger } from '@automaker/utils';
import { getErrorMessage, logError } from '../common.js';
const logger = createLogger('AutoMode');
export function createStopHandler(autoModeService: AutoModeService) {
export function createStopHandler(autoModeService: AutoModeServiceCompat) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, branchName } = req.body as {

View File

@@ -3,10 +3,10 @@
*/
import type { Request, Response } from 'express';
import type { AutoModeService } from '../../../services/auto-mode-service.js';
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
import { getErrorMessage, logError } from '../common.js';
export function createVerifyFeatureHandler(autoModeService: AutoModeService) {
export function createVerifyFeatureHandler(autoModeService: AutoModeServiceCompat) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, featureId } = req.body as {

View File

@@ -114,9 +114,20 @@ export function mapBacklogPlanError(rawMessage: string): string {
return 'Claude CLI could not be launched. Make sure the Claude CLI is installed and available in PATH, or check that Node.js is correctly installed. Try running "which claude" or "claude --version" in your terminal to verify.';
}
// Claude Code process crash
// Claude Code process crash - extract exit code for diagnostics
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.';
const exitCodeMatch = rawMessage.match(/exited with code (\d+)/);
const exitCode = exitCodeMatch ? exitCodeMatch[1] : 'unknown';
logger.error(`[BacklogPlan] Claude process exit code: ${exitCode}`);
return `Claude exited unexpectedly (exit code: ${exitCode}). This is usually a transient issue. Try again. If it keeps happening, re-run \`claude login\` or update your API key in Setup.`;
}
// Claude Code process killed by signal
if (rawMessage.includes('Claude Code process terminated by signal')) {
const signalMatch = rawMessage.match(/terminated by signal (\w+)/);
const signal = signalMatch ? signalMatch[1] : 'unknown';
logger.error(`[BacklogPlan] Claude process terminated by signal: ${signal}`);
return `Claude was terminated by signal ${signal}. This may indicate a resource issue. Try again.`;
}
// Rate limiting

View File

@@ -3,17 +3,22 @@
*
* Model is configurable via phaseModels.backlogPlanningModel in settings
* (defaults to Sonnet). Can be overridden per-call via model parameter.
*
* Includes automatic retry for transient CLI failures (e.g., "Claude Code
* process exited unexpectedly") to improve reliability.
*/
import type { EventEmitter } from '../../lib/events.js';
import type { Feature, BacklogPlanResult, BacklogChange, DependencyUpdate } from '@automaker/types';
import type { Feature, BacklogPlanResult } from '@automaker/types';
import {
DEFAULT_PHASE_MODELS,
isCursorModel,
stripProviderPrefix,
type ThinkingLevel,
type SystemPromptPreset,
} from '@automaker/types';
import { resolvePhaseModel } from '@automaker/model-resolver';
import { getCurrentBranch } from '@automaker/git-utils';
import { FeatureLoader } from '../../services/feature-loader.js';
import { ProviderFactory } from '../../providers/provider-factory.js';
import { extractJsonWithArray } from '../../lib/json-extractor.js';
@@ -27,10 +32,28 @@ import {
import type { SettingsService } from '../../services/settings-service.js';
import {
getAutoLoadClaudeMdSetting,
getUseClaudeCodeSystemPromptSetting,
getPromptCustomization,
getPhaseModelWithOverrides,
getProviderByModelId,
} from '../../lib/settings-helpers.js';
/** Maximum number of retry attempts for transient CLI failures */
const MAX_RETRIES = 2;
/** Delay between retries in milliseconds */
const RETRY_DELAY_MS = 2000;
/**
* Check if an error is retryable (transient CLI process failure)
*/
function isRetryableError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return (
message.includes('Claude Code process exited') ||
message.includes('Claude Code process terminated by signal')
);
}
const featureLoader = new FeatureLoader();
/**
@@ -84,6 +107,53 @@ function parsePlanResponse(response: string): BacklogPlanResult {
};
}
/**
* Try to parse a valid plan response without fallback behavior.
* Returns null if parsing fails.
*/
function tryParsePlanResponse(response: string): BacklogPlanResult | null {
if (!response || response.trim().length === 0) {
return null;
}
return extractJsonWithArray<BacklogPlanResult>(response, 'changes', { logger });
}
/**
* Choose the most reliable response text between streamed assistant chunks
* and provider final result payload.
*/
function selectBestResponseText(accumulatedText: string, providerResultText: string): string {
const hasAccumulated = accumulatedText.trim().length > 0;
const hasProviderResult = providerResultText.trim().length > 0;
if (!hasProviderResult) {
return accumulatedText;
}
if (!hasAccumulated) {
return providerResultText;
}
const accumulatedParsed = tryParsePlanResponse(accumulatedText);
const providerParsed = tryParsePlanResponse(providerResultText);
if (providerParsed && !accumulatedParsed) {
logger.info('[BacklogPlan] Using provider result (parseable JSON)');
return providerResultText;
}
if (accumulatedParsed && !providerParsed) {
logger.info('[BacklogPlan] Keeping accumulated text (parseable JSON)');
return accumulatedText;
}
if (providerResultText.length > accumulatedText.length) {
logger.info('[BacklogPlan] Using provider result (longer content)');
return providerResultText;
}
logger.info('[BacklogPlan] Keeping accumulated text (longer content)');
return accumulatedText;
}
/**
* Generate a backlog modification plan based on user prompt
*/
@@ -93,11 +163,40 @@ export async function generateBacklogPlan(
events: EventEmitter,
abortController: AbortController,
settingsService?: SettingsService,
model?: string
model?: string,
branchName?: string
): Promise<BacklogPlanResult> {
try {
// Load current features
const features = await featureLoader.getAll(projectPath);
const allFeatures = await featureLoader.getAll(projectPath);
// Filter features by branch if specified (worktree-scoped backlog)
let features: Feature[];
if (branchName) {
// Determine the primary branch so unassigned features show for the main worktree
let primaryBranch: string | null = null;
try {
primaryBranch = await getCurrentBranch(projectPath);
} catch {
// If git fails, fall back to 'main' so unassigned features are visible
// when branchName matches a common default branch name
primaryBranch = 'main';
}
const isMainBranch = branchName === primaryBranch;
features = allFeatures.filter((f) => {
if (!f.branchName) {
// Unassigned features belong to the main/primary worktree
return isMainBranch;
}
return f.branchName === branchName;
});
logger.info(
`[BacklogPlan] Filtered to ${features.length}/${allFeatures.length} features for branch: ${branchName}`
);
} else {
features = allFeatures;
}
events.emit('backlog-plan:event', {
type: 'backlog_plan_progress',
@@ -133,6 +232,35 @@ export async function generateBacklogPlan(
effectiveModel = resolved.model;
thinkingLevel = resolved.thinkingLevel;
credentials = await settingsService?.getCredentials();
// Resolve Claude-compatible provider when client sends a model (e.g. MiniMax, GLM)
if (settingsService) {
const providerResult = await getProviderByModelId(
effectiveModel,
settingsService,
'[BacklogPlan]'
);
if (providerResult.provider) {
claudeCompatibleProvider = providerResult.provider;
if (providerResult.credentials) {
credentials = providerResult.credentials;
}
}
// Fallback: use phase settings provider if model lookup found nothing (e.g. model
// string format differs from provider's model id, but backlog planning phase has providerId).
if (!claudeCompatibleProvider) {
const phaseResult = await getPhaseModelWithOverrides(
'backlogPlanningModel',
settingsService,
projectPath,
'[BacklogPlan]'
);
const phaseResolved = resolvePhaseModel(phaseResult.phaseModel);
if (phaseResult.provider && phaseResolved.model === effectiveModel) {
claudeCompatibleProvider = phaseResult.provider;
credentials = phaseResult.credentials ?? credentials;
}
}
}
} else if (settingsService) {
// Use settings-based model with provider info
const phaseResult = await getPhaseModelWithOverrides(
@@ -162,17 +290,23 @@ export async function generateBacklogPlan(
// Strip provider prefix - providers expect bare model IDs
const bareModel = stripProviderPrefix(effectiveModel);
// Get autoLoadClaudeMd setting
// Get autoLoadClaudeMd and useClaudeCodeSystemPrompt settings
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
projectPath,
settingsService,
'[BacklogPlan]'
);
const useClaudeCodeSystemPrompt = await getUseClaudeCodeSystemPromptSetting(
projectPath,
settingsService,
'[BacklogPlan]'
);
// For Cursor models, we need to combine prompts with explicit instructions
// because Cursor doesn't support systemPrompt separation like Claude SDK
let finalPrompt = userPrompt;
let finalSystemPrompt: string | undefined = systemPrompt;
let finalSystemPrompt: string | SystemPromptPreset | undefined = systemPrompt;
let finalSettingSources: Array<'user' | 'project' | 'local'> | undefined;
if (isCursorModel(effectiveModel)) {
logger.info('[BacklogPlan] Using Cursor model - adding explicit no-file-write instructions');
@@ -187,54 +321,145 @@ CRITICAL INSTRUCTIONS:
${userPrompt}`;
finalSystemPrompt = undefined; // System prompt is now embedded in the user prompt
} else if (claudeCompatibleProvider) {
// Claude-compatible providers (MiniMax, GLM, etc.) use a plain API; do not use
// the claude_code preset (which is for Claude CLI/subprocess and can break the request).
finalSystemPrompt = systemPrompt;
} else if (useClaudeCodeSystemPrompt) {
// Use claude_code preset for native Claude so the SDK subprocess
// authenticates via CLI OAuth or API key the same way all other SDK calls do.
finalSystemPrompt = {
type: 'preset',
preset: 'claude_code',
append: systemPrompt,
};
}
// Include settingSources when autoLoadClaudeMd is enabled
if (autoLoadClaudeMd) {
finalSettingSources = ['user', 'project'];
}
// Execute the query
const stream = provider.executeQuery({
// Execute the query with retry logic for transient CLI failures
const queryOptions = {
prompt: finalPrompt,
model: bareModel,
cwd: projectPath,
systemPrompt: finalSystemPrompt,
maxTurns: 1,
allowedTools: [], // No tools needed for this
tools: [] as string[], // Disable all built-in tools - plan generation only needs text output
abortController,
settingSources: autoLoadClaudeMd ? ['user', 'project'] : undefined,
readOnly: true, // Plan generation only generates text, doesn't write files
settingSources: finalSettingSources,
thinkingLevel, // Pass thinking level for extended thinking
claudeCompatibleProvider, // Pass provider for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
});
};
let responseText = '';
let bestResponseText = ''; // Preserve best response across all retry attempts
let recoveredResult: BacklogPlanResult | null = null;
let lastError: unknown = null;
for await (const msg of stream) {
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
if (abortController.signal.aborted) {
throw new Error('Generation aborted');
}
if (msg.type === 'assistant') {
if (msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text') {
responseText += block.text;
if (attempt > 0) {
logger.info(
`[BacklogPlan] Retry attempt ${attempt}/${MAX_RETRIES} after transient failure`
);
events.emit('backlog-plan:event', {
type: 'backlog_plan_progress',
content: `Retrying... (attempt ${attempt + 1}/${MAX_RETRIES + 1})`,
});
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
}
let accumulatedText = '';
let providerResultText = '';
try {
const stream = provider.executeQuery(queryOptions);
for await (const msg of stream) {
if (abortController.signal.aborted) {
throw new Error('Generation aborted');
}
if (msg.type === 'assistant') {
if (msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text') {
accumulatedText += block.text;
}
}
}
} else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) {
providerResultText = msg.result;
logger.info(
'[BacklogPlan] Received result from provider, length:',
providerResultText.length
);
logger.info('[BacklogPlan] Accumulated response length:', accumulatedText.length);
}
}
} else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) {
// Use result if it's a final accumulated message (from Cursor provider)
logger.info('[BacklogPlan] Received result from Cursor, length:', msg.result.length);
logger.info('[BacklogPlan] Previous responseText length:', responseText.length);
if (msg.result.length > responseText.length) {
logger.info('[BacklogPlan] Using Cursor result (longer than accumulated text)');
responseText = msg.result;
} else {
logger.info('[BacklogPlan] Keeping accumulated text (longer than Cursor result)');
responseText = selectBestResponseText(accumulatedText, providerResultText);
// If we got here, the stream completed successfully
lastError = null;
break;
} catch (error) {
lastError = error;
const errorMessage = error instanceof Error ? error.message : String(error);
responseText = selectBestResponseText(accumulatedText, providerResultText);
// Preserve the best response text across all attempts so that if a retry
// crashes immediately (empty response), we can still recover from an earlier attempt
bestResponseText = selectBestResponseText(bestResponseText, responseText);
// Claude SDK can occasionally exit non-zero after emitting a complete response.
// If we already have valid JSON, recover instead of failing the entire planning flow.
if (isRetryableError(error)) {
const parsed = tryParsePlanResponse(bestResponseText);
if (parsed) {
logger.warn(
'[BacklogPlan] Recovered from transient CLI exit using accumulated valid response'
);
recoveredResult = parsed;
lastError = null;
break;
}
// On final retryable failure, degrade gracefully if we have text from any attempt.
if (attempt >= MAX_RETRIES && bestResponseText.trim().length > 0) {
logger.warn(
'[BacklogPlan] Final retryable CLI failure with non-empty response, attempting fallback parse'
);
recoveredResult = parsePlanResponse(bestResponseText);
lastError = null;
break;
}
}
// Only retry on transient CLI failures, not on user aborts or other errors
if (!isRetryableError(error) || attempt >= MAX_RETRIES) {
throw error;
}
logger.warn(
`[BacklogPlan] Transient CLI failure (attempt ${attempt + 1}/${MAX_RETRIES + 1}): ${errorMessage}`
);
}
}
// If we exhausted retries, throw the last error
if (lastError) {
throw lastError;
}
// Parse the response
const result = parsePlanResponse(responseText);
const result = recoveredResult ?? parsePlanResponse(responseText);
await saveBacklogPlan(projectPath, {
savedAt: new Date().toISOString(),

View File

@@ -25,7 +25,7 @@ export function createBacklogPlanRoutes(
);
router.post('/stop', createStopHandler());
router.get('/status', validatePathParams('projectPath'), createStatusHandler());
router.post('/apply', validatePathParams('projectPath'), createApplyHandler());
router.post('/apply', validatePathParams('projectPath'), createApplyHandler(settingsService));
router.post('/clear', validatePathParams('projectPath'), createClearHandler());
return router;

View File

@@ -3,13 +3,23 @@
*/
import type { Request, Response } from 'express';
import type { BacklogPlanResult, BacklogChange, Feature } from '@automaker/types';
import { resolvePhaseModel } from '@automaker/model-resolver';
import type { BacklogPlanResult, PhaseModelEntry, PlanningMode } from '@automaker/types';
import { FeatureLoader } from '../../../services/feature-loader.js';
import type { SettingsService } from '../../../services/settings-service.js';
import { clearBacklogPlan, getErrorMessage, logError, logger } from '../common.js';
const featureLoader = new FeatureLoader();
export function createApplyHandler() {
function normalizePhaseModelEntry(
entry: PhaseModelEntry | string | undefined | null
): PhaseModelEntry | undefined {
if (!entry) return undefined;
if (typeof entry === 'string') return { model: entry };
return entry;
}
export function createApplyHandler(settingsService?: SettingsService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const {
@@ -38,6 +48,23 @@ export function createApplyHandler() {
return;
}
let defaultPlanningMode: PlanningMode = 'skip';
let defaultRequirePlanApproval = false;
let defaultModelEntry: PhaseModelEntry | undefined;
if (settingsService) {
const globalSettings = await settingsService.getGlobalSettings();
const projectSettings = await settingsService.getProjectSettings(projectPath);
defaultPlanningMode = globalSettings.defaultPlanningMode ?? 'skip';
defaultRequirePlanApproval = globalSettings.defaultRequirePlanApproval ?? false;
defaultModelEntry = normalizePhaseModelEntry(
projectSettings.defaultFeatureModel ?? globalSettings.defaultFeatureModel
);
}
const resolvedDefaultModel = resolvePhaseModel(defaultModelEntry);
const appliedChanges: string[] = [];
// Load current features for dependency validation
@@ -58,6 +85,9 @@ export function createApplyHandler() {
if (feature.dependencies?.includes(change.featureId)) {
const newDeps = feature.dependencies.filter((d) => d !== change.featureId);
await featureLoader.update(projectPath, feature.id, { dependencies: newDeps });
// Mutate the in-memory feature object so subsequent deletions use the updated
// dependency list and don't reintroduce already-removed dependency IDs.
feature.dependencies = newDeps;
logger.info(
`[BacklogPlan] Removed dependency ${change.featureId} from ${feature.id}`
);
@@ -85,6 +115,12 @@ export function createApplyHandler() {
if (!change.feature) continue;
try {
const effectivePlanningMode = change.feature.planningMode ?? defaultPlanningMode;
const effectiveRequirePlanApproval =
effectivePlanningMode === 'skip' || effectivePlanningMode === 'lite'
? false
: (change.feature.requirePlanApproval ?? defaultRequirePlanApproval);
// Create the new feature - use the AI-generated ID if provided
const newFeature = await featureLoader.create(projectPath, {
id: change.feature.id, // Use descriptive ID from AI if provided
@@ -94,6 +130,12 @@ export function createApplyHandler() {
dependencies: change.feature.dependencies,
priority: change.feature.priority,
status: 'backlog',
model: change.feature.model ?? resolvedDefaultModel.model,
thinkingLevel: change.feature.thinkingLevel ?? resolvedDefaultModel.thinkingLevel,
reasoningEffort: change.feature.reasoningEffort ?? resolvedDefaultModel.reasoningEffort,
providerId: change.feature.providerId ?? resolvedDefaultModel.providerId,
planningMode: effectivePlanningMode,
requirePlanApproval: effectiveRequirePlanApproval,
branchName,
});

View File

@@ -17,10 +17,11 @@ import type { SettingsService } from '../../../services/settings-service.js';
export function createGenerateHandler(events: EventEmitter, settingsService?: SettingsService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, prompt, model } = req.body as {
const { projectPath, prompt, model, branchName } = req.body as {
projectPath: string;
prompt: string;
model?: string;
branchName?: string;
};
if (!projectPath) {
@@ -42,28 +43,30 @@ export function createGenerateHandler(events: EventEmitter, settingsService?: Se
return;
}
setRunningState(true);
const abortController = new AbortController();
setRunningState(true, abortController);
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)');
})
.finally(() => {
setRunningState(false, null);
setRunningDetails(null);
});
// Note: generateBacklogPlan handles its own error event emission
// and state cleanup in its finally block, so we only log here
generateBacklogPlan(
projectPath,
prompt,
events,
abortController,
settingsService,
model,
branchName
).catch((error) => {
// Just log - error event already emitted by generateBacklogPlan
logError(error, 'Generate backlog plan failed (background)');
});
res.json({ success: true });
} catch (error) {

View File

@@ -142,11 +142,33 @@ function mapDescribeImageError(rawMessage: string | undefined): {
if (!rawMessage) return baseResponse;
if (rawMessage.includes('Claude Code process exited')) {
if (
rawMessage.includes('Claude Code process exited') ||
rawMessage.includes('Claude Code process terminated by signal')
) {
const exitCodeMatch = rawMessage.match(/exited with code (\d+)/);
const signalMatch = rawMessage.match(/terminated by signal (\w+)/);
const detail = exitCodeMatch
? ` (exit code: ${exitCodeMatch[1]})`
: signalMatch
? ` (signal: ${signalMatch[1]})`
: '';
// Crash/OS-kill signals suggest a process crash, not an auth failure —
// omit auth recovery advice and suggest retry/reporting instead.
const crashSignals = ['SIGSEGV', 'SIGABRT', 'SIGKILL', 'SIGBUS', 'SIGTRAP'];
const isCrashSignal = signalMatch ? crashSignals.includes(signalMatch[1]) : false;
if (isCrashSignal) {
return {
statusCode: 503,
userMessage: `Claude crashed unexpectedly${detail} while describing the image. This may be a transient condition. Please try again. If the problem persists, collect logs and report the issue.`,
};
}
return {
statusCode: 503,
userMessage:
'Claude exited unexpectedly while describing the image. Try again. If it keeps happening, re-run `claude login` or update your API key in Setup so Claude can restart cleanly.',
userMessage: `Claude exited unexpectedly${detail} while describing the image. This is usually a transient issue. Try again. If it keeps happening, re-run \`claude login\` or update your API key in Setup.`,
};
}

View File

@@ -219,18 +219,21 @@ export function createEnhanceHandler(
}
}
// Resolve the model - use provider resolved model, passed model, or default to sonnet
const resolvedModel =
providerResolvedModel || resolveModelString(model, CLAUDE_MODEL_MAP.sonnet);
// Resolve the model for API call.
// CRITICAL: For custom providers (GLM, MiniMax), pass the provider's model ID (e.g. "GLM-4.7")
// to the API, NOT the resolved Claude model - otherwise we get "model not found"
const modelForApi = claudeCompatibleProvider
? model
: providerResolvedModel || resolveModelString(model, CLAUDE_MODEL_MAP.sonnet);
logger.debug(`Using model: ${resolvedModel}`);
logger.debug(`Using model: ${modelForApi}`);
// Use simpleQuery - provider abstraction handles routing to correct provider
// The system prompt is combined with user prompt since some providers
// don't have a separate system prompt concept
const result = await simpleQuery({
prompt: [systemPrompt, projectContext, userPrompt].filter(Boolean).join('\n\n'),
model: resolvedModel,
model: modelForApi,
cwd: process.cwd(), // Enhancement doesn't need a specific working directory
maxTurns: 1,
allowedTools: [],

View File

@@ -5,7 +5,7 @@
import { Router } from 'express';
import { FeatureLoader } from '../../services/feature-loader.js';
import type { SettingsService } from '../../services/settings-service.js';
import type { AutoModeService } from '../../services/auto-mode-service.js';
import type { AutoModeServiceCompat } from '../../services/auto-mode/index.js';
import type { EventEmitter } from '../../lib/events.js';
import { validatePathParams } from '../../middleware/validate-paths.js';
import { createListHandler } from './routes/list.js';
@@ -19,12 +19,17 @@ import { createAgentOutputHandler, createRawOutputHandler } from './routes/agent
import { createGenerateTitleHandler } from './routes/generate-title.js';
import { createExportHandler } from './routes/export.js';
import { createImportHandler, createConflictCheckHandler } from './routes/import.js';
import {
createOrphanedListHandler,
createOrphanedResolveHandler,
createOrphanedBulkResolveHandler,
} from './routes/orphaned.js';
export function createFeaturesRoutes(
featureLoader: FeatureLoader,
settingsService?: SettingsService,
events?: EventEmitter,
autoModeService?: AutoModeService
autoModeService?: AutoModeServiceCompat
): Router {
const router = Router();
@@ -33,13 +38,22 @@ export function createFeaturesRoutes(
validatePathParams('projectPath'),
createListHandler(featureLoader, autoModeService)
);
router.get(
'/list',
validatePathParams('projectPath'),
createListHandler(featureLoader, autoModeService)
);
router.post('/get', validatePathParams('projectPath'), createGetHandler(featureLoader));
router.post(
'/create',
validatePathParams('projectPath'),
createCreateHandler(featureLoader, events)
);
router.post('/update', validatePathParams('projectPath'), createUpdateHandler(featureLoader));
router.post(
'/update',
validatePathParams('projectPath'),
createUpdateHandler(featureLoader, events)
);
router.post(
'/bulk-update',
validatePathParams('projectPath'),
@@ -61,6 +75,21 @@ export function createFeaturesRoutes(
validatePathParams('projectPath'),
createConflictCheckHandler(featureLoader)
);
router.post(
'/orphaned',
validatePathParams('projectPath'),
createOrphanedListHandler(featureLoader, autoModeService)
);
router.post(
'/orphaned/resolve',
validatePathParams('projectPath'),
createOrphanedResolveHandler(featureLoader, autoModeService)
);
router.post(
'/orphaned/bulk-resolve',
validatePathParams('projectPath'),
createOrphanedBulkResolveHandler(featureLoader)
);
return router;
}

View File

@@ -24,19 +24,6 @@ export function createCreateHandler(featureLoader: FeatureLoader, events?: Event
return;
}
// Check for duplicate title if title is provided
if (feature.title && feature.title.trim()) {
const duplicate = await featureLoader.findDuplicateTitle(projectPath, feature.title);
if (duplicate) {
res.status(409).json({
success: false,
error: `A feature with title "${feature.title}" already exists`,
duplicateFeatureId: duplicate.id,
});
return;
}
}
const created = await featureLoader.create(projectPath, feature);
// Emit feature_created event for hooks

View File

@@ -36,7 +36,7 @@ interface ExportRequest {
};
}
export function createExportHandler(featureLoader: FeatureLoader) {
export function createExportHandler(_featureLoader: FeatureLoader) {
const exportService = getFeatureExportService();
return async (req: Request, res: Response): Promise<void> => {

View File

@@ -34,7 +34,7 @@ export function createGenerateTitleHandler(
): (req: Request, res: Response) => Promise<void> {
return async (req: Request, res: Response): Promise<void> => {
try {
const { description, projectPath } = req.body as GenerateTitleRequestBody;
const { description } = req.body as GenerateTitleRequestBody;
if (!description || typeof description !== 'string') {
const response: GenerateTitleErrorResponse = {

View File

@@ -33,7 +33,7 @@ interface ConflictInfo {
hasConflict: boolean;
}
export function createImportHandler(featureLoader: FeatureLoader) {
export function createImportHandler(_featureLoader: FeatureLoader) {
const exportService = getFeatureExportService();
return async (req: Request, res: Response): Promise<void> => {

View File

@@ -1,5 +1,7 @@
/**
* POST /list endpoint - List all features for a project
* POST/GET /list endpoint - List all features for a project
*
* projectPath may come from req.body (POST) or req.query (GET fallback).
*
* Also performs orphan detection when a project is loaded to identify
* features whose branches no longer exist. This runs on every project load/switch.
@@ -7,16 +9,29 @@
import type { Request, Response } from 'express';
import { FeatureLoader } from '../../../services/feature-loader.js';
import type { AutoModeService } from '../../../services/auto-mode-service.js';
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
import { getErrorMessage, logError } from '../common.js';
import { createLogger } from '@automaker/utils';
const logger = createLogger('FeaturesListRoute');
export function createListHandler(featureLoader: FeatureLoader, autoModeService?: AutoModeService) {
export function createListHandler(
featureLoader: FeatureLoader,
autoModeService?: AutoModeServiceCompat
) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath } = req.body as { projectPath: string };
const bodyProjectPath =
typeof req.body === 'object' && req.body !== null
? (req.body as { projectPath?: unknown }).projectPath
: undefined;
const queryProjectPath = req.query.projectPath;
const projectPath =
typeof bodyProjectPath === 'string'
? bodyProjectPath
: typeof queryProjectPath === 'string'
? queryProjectPath
: undefined;
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
@@ -30,18 +45,23 @@ export function createListHandler(featureLoader: FeatureLoader, autoModeService?
// We don't await this to keep the list response fast
// Note: detectOrphanedFeatures handles errors internally and always resolves
if (autoModeService) {
autoModeService.detectOrphanedFeatures(projectPath).then((orphanedFeatures) => {
if (orphanedFeatures.length > 0) {
logger.info(
`[ProjectLoad] Detected ${orphanedFeatures.length} orphaned feature(s) in ${projectPath}`
);
for (const { feature, missingBranch } of orphanedFeatures) {
autoModeService
.detectOrphanedFeatures(projectPath, features)
.then((orphanedFeatures) => {
if (orphanedFeatures.length > 0) {
logger.info(
`[ProjectLoad] Orphaned: ${feature.title || feature.id} - branch "${missingBranch}" no longer exists`
`[ProjectLoad] Detected ${orphanedFeatures.length} orphaned feature(s) in ${projectPath}`
);
for (const { feature, missingBranch } of orphanedFeatures) {
logger.info(
`[ProjectLoad] Orphaned: ${feature.title || feature.id} - branch "${missingBranch}" no longer exists`
);
}
}
}
});
})
.catch((error) => {
logger.warn(`[ProjectLoad] Orphan detection failed for ${projectPath}:`, error);
});
}
res.json({ success: true, features });

View File

@@ -0,0 +1,287 @@
/**
* POST /orphaned endpoint - Detect orphaned features (features with missing branches)
* POST /orphaned/resolve endpoint - Resolve an orphaned feature (delete, create-worktree, or move-to-branch)
* POST /orphaned/bulk-resolve endpoint - Resolve multiple orphaned features at once
*/
import crypto from 'crypto';
import path from 'path';
import type { Request, Response } from 'express';
import { FeatureLoader } from '../../../services/feature-loader.js';
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
import { getErrorMessage, logError } from '../common.js';
import { execGitCommand } from '../../../lib/git.js';
import { deleteWorktreeMetadata } from '../../../lib/worktree-metadata.js';
import { createLogger } from '@automaker/utils';
const logger = createLogger('OrphanedFeatures');
type ResolveAction = 'delete' | 'create-worktree' | 'move-to-branch';
const VALID_ACTIONS: ResolveAction[] = ['delete', 'create-worktree', 'move-to-branch'];
export function createOrphanedListHandler(
featureLoader: FeatureLoader,
autoModeService?: AutoModeServiceCompat
) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath } = req.body as { projectPath: string };
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
if (!autoModeService) {
res.status(500).json({ success: false, error: 'Auto-mode service not available' });
return;
}
const orphanedFeatures = await autoModeService.detectOrphanedFeatures(projectPath);
res.json({ success: true, orphanedFeatures });
} catch (error) {
logError(error, 'Detect orphaned features failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}
export function createOrphanedResolveHandler(
featureLoader: FeatureLoader,
_autoModeService?: AutoModeServiceCompat
) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, featureId, action, targetBranch } = req.body as {
projectPath: string;
featureId: string;
action: ResolveAction;
targetBranch?: string | null;
};
if (!projectPath || !featureId || !action) {
res.status(400).json({
success: false,
error: 'projectPath, featureId, and action are required',
});
return;
}
if (!VALID_ACTIONS.includes(action)) {
res.status(400).json({
success: false,
error: `action must be one of: ${VALID_ACTIONS.join(', ')}`,
});
return;
}
const result = await resolveOrphanedFeature(
featureLoader,
projectPath,
featureId,
action,
targetBranch
);
if (!result.success) {
res.status(result.error === 'Feature not found' ? 404 : 500).json(result);
return;
}
res.json(result);
} catch (error) {
logError(error, 'Resolve orphaned feature failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}
interface BulkResolveResult {
featureId: string;
success: boolean;
action?: string;
error?: string;
}
async function resolveOrphanedFeature(
featureLoader: FeatureLoader,
projectPath: string,
featureId: string,
action: ResolveAction,
targetBranch?: string | null
): Promise<BulkResolveResult> {
try {
const feature = await featureLoader.get(projectPath, featureId);
if (!feature) {
return { featureId, success: false, error: 'Feature not found' };
}
const missingBranch = feature.branchName;
switch (action) {
case 'delete': {
if (missingBranch) {
try {
await deleteWorktreeMetadata(projectPath, missingBranch);
} catch {
// Non-fatal
}
}
const success = await featureLoader.delete(projectPath, featureId);
if (!success) {
return { featureId, success: false, error: 'Deletion failed' };
}
logger.info(`Deleted orphaned feature ${featureId} (branch: ${missingBranch})`);
return { featureId, success: true, action: 'deleted' };
}
case 'create-worktree': {
if (!missingBranch) {
return { featureId, success: false, error: 'Feature has no branch name to recreate' };
}
const sanitizedName = missingBranch.replace(/[^a-zA-Z0-9_-]/g, '-');
const hash = crypto.createHash('sha1').update(missingBranch).digest('hex').slice(0, 8);
const worktreesDir = path.join(projectPath, '.worktrees');
const worktreePath = path.join(worktreesDir, `${sanitizedName}-${hash}`);
try {
await execGitCommand(['worktree', 'add', '-b', missingBranch, worktreePath], projectPath);
} catch (error) {
const msg = getErrorMessage(error);
if (msg.includes('already exists')) {
try {
await execGitCommand(['worktree', 'add', worktreePath, missingBranch], projectPath);
} catch (innerError) {
return {
featureId,
success: false,
error: `Failed to create worktree: ${getErrorMessage(innerError)}`,
};
}
} else {
return { featureId, success: false, error: `Failed to create worktree: ${msg}` };
}
}
logger.info(
`Created worktree for orphaned feature ${featureId} at ${worktreePath} (branch: ${missingBranch})`
);
return { featureId, success: true, action: 'worktree-created' };
}
case 'move-to-branch': {
// Move the feature to a different branch (or clear branch to use main worktree)
const newBranch = targetBranch || null;
// Validate that the target branch exists if one is specified
if (newBranch) {
try {
await execGitCommand(['rev-parse', '--verify', newBranch], projectPath);
} catch {
return {
featureId,
success: false,
error: `Target branch "${newBranch}" does not exist`,
};
}
}
await featureLoader.update(projectPath, featureId, {
branchName: newBranch,
status: 'pending',
});
// Clean up old worktree metadata
if (missingBranch) {
try {
await deleteWorktreeMetadata(projectPath, missingBranch);
} catch {
// Non-fatal
}
}
const destination = newBranch ?? 'main worktree';
logger.info(
`Moved orphaned feature ${featureId} to ${destination} (was: ${missingBranch})`
);
return { featureId, success: true, action: 'moved' };
}
}
} catch (error) {
return { featureId, success: false, error: getErrorMessage(error) };
}
}
export function createOrphanedBulkResolveHandler(featureLoader: FeatureLoader) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, featureIds, action, targetBranch } = req.body as {
projectPath: string;
featureIds: string[];
action: ResolveAction;
targetBranch?: string | null;
};
if (
!projectPath ||
!featureIds ||
!Array.isArray(featureIds) ||
featureIds.length === 0 ||
!action
) {
res.status(400).json({
success: false,
error: 'projectPath, featureIds (non-empty array), and action are required',
});
return;
}
if (!VALID_ACTIONS.includes(action)) {
res.status(400).json({
success: false,
error: `action must be one of: ${VALID_ACTIONS.join(', ')}`,
});
return;
}
// Process sequentially for worktree creation (git operations shouldn't race),
// in parallel for delete/move-to-branch
const results: BulkResolveResult[] = [];
if (action === 'create-worktree') {
for (const featureId of featureIds) {
const result = await resolveOrphanedFeature(
featureLoader,
projectPath,
featureId,
action,
targetBranch
);
results.push(result);
}
} else {
const batchResults = await Promise.all(
featureIds.map((featureId) =>
resolveOrphanedFeature(featureLoader, projectPath, featureId, action, targetBranch)
)
);
results.push(...batchResults);
}
const successCount = results.filter((r) => r.success).length;
const failedCount = results.length - successCount;
res.json({
success: failedCount === 0,
resolvedCount: successCount,
failedCount,
results,
});
} catch (error) {
logError(error, 'Bulk resolve orphaned features failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -5,6 +5,7 @@
import type { Request, Response } from 'express';
import { FeatureLoader } from '../../../services/feature-loader.js';
import type { Feature, FeatureStatus } from '@automaker/types';
import type { EventEmitter } from '../../../lib/events.js';
import { getErrorMessage, logError } from '../common.js';
import { createLogger } from '@automaker/utils';
@@ -13,7 +14,7 @@ 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) {
export function createUpdateHandler(featureLoader: FeatureLoader, events?: EventEmitter) {
return async (req: Request, res: Response): Promise<void> => {
try {
const {
@@ -40,26 +41,13 @@ 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;
if (!currentFeature) {
res.status(404).json({ success: false, error: `Feature ${featureId} not found` });
return;
}
const previousStatus = currentFeature.status as FeatureStatus;
const newStatus = updates.status as FeatureStatus | undefined;
const updated = await featureLoader.update(
@@ -71,8 +59,18 @@ export function createUpdateHandler(featureLoader: FeatureLoader) {
preEnhancementDescription
);
// Trigger sync to app_spec.txt when status changes to verified or completed
// Emit completion event and sync to app_spec.txt when status transitions to verified/completed
if (newStatus && SYNC_TRIGGER_STATUSES.includes(newStatus) && previousStatus !== newStatus) {
events?.emit('feature:completed', {
featureId,
featureName: updated.title,
projectPath,
passes: true,
message:
newStatus === 'verified' ? 'Feature verified manually' : 'Feature completed manually',
executionMode: 'manual',
});
try {
const synced = await featureLoader.syncFeatureToAppSpec(projectPath, updated);
if (synced) {

View File

@@ -19,6 +19,10 @@ import { createBrowseHandler } from './routes/browse.js';
import { createImageHandler } from './routes/image.js';
import { createSaveBoardBackgroundHandler } from './routes/save-board-background.js';
import { createDeleteBoardBackgroundHandler } from './routes/delete-board-background.js';
import { createBrowseProjectFilesHandler } from './routes/browse-project-files.js';
import { createCopyHandler } from './routes/copy.js';
import { createMoveHandler } from './routes/move.js';
import { createDownloadHandler } from './routes/download.js';
export function createFsRoutes(_events: EventEmitter): Router {
const router = Router();
@@ -37,6 +41,10 @@ export function createFsRoutes(_events: EventEmitter): Router {
router.get('/image', createImageHandler());
router.post('/save-board-background', createSaveBoardBackgroundHandler());
router.post('/delete-board-background', createDeleteBoardBackgroundHandler());
router.post('/browse-project-files', createBrowseProjectFilesHandler());
router.post('/copy', createCopyHandler());
router.post('/move', createMoveHandler());
router.post('/download', createDownloadHandler());
return router;
}

View File

@@ -0,0 +1,191 @@
/**
* POST /browse-project-files endpoint - Browse files and directories within a project
*
* Unlike /browse which only lists directories (for project folder selection),
* this endpoint lists both files and directories relative to a project root.
* Used by the file selector for "Copy files to worktree" settings.
*
* Features:
* - Lists both files and directories
* - Hides .git, .worktrees, node_modules, and other build artifacts
* - Returns entries relative to the project root
* - Supports navigating into subdirectories
* - Security: prevents path traversal outside project root
*/
import type { Request, Response } from 'express';
import * as secureFs from '../../../lib/secure-fs.js';
import path from 'path';
import { PathNotAllowedError } from '@automaker/platform';
import { getErrorMessage, logError } from '../common.js';
// Directories to hide from the listing (build artifacts, caches, etc.)
const HIDDEN_DIRECTORIES = new Set([
'.git',
'.worktrees',
'node_modules',
'.automaker',
'__pycache__',
'.cache',
'.next',
'.nuxt',
'.svelte-kit',
'.turbo',
'.vercel',
'.output',
'coverage',
'.nyc_output',
'dist',
'build',
'out',
'.tmp',
'tmp',
'.venv',
'venv',
'target',
'vendor',
'.gradle',
'.idea',
'.vscode',
]);
interface ProjectFileEntry {
name: string;
relativePath: string;
isDirectory: boolean;
isFile: boolean;
}
export function createBrowseProjectFilesHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, relativePath } = req.body as {
projectPath: string;
relativePath?: string; // Relative path within the project to browse (empty = project root)
};
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
const resolvedProjectPath = path.resolve(projectPath);
// Determine the target directory to browse
let targetPath = resolvedProjectPath;
let currentRelativePath = '';
if (relativePath) {
// Security: normalize and validate the relative path
const normalized = path.normalize(relativePath);
if (normalized.startsWith('..') || path.isAbsolute(normalized)) {
res.status(400).json({
success: false,
error: 'Invalid relative path - must be within the project directory',
});
return;
}
targetPath = path.join(resolvedProjectPath, normalized);
currentRelativePath = normalized;
// Double-check the resolved path is within the project
// Use a separator-terminated prefix to prevent matching sibling dirs
// that share the same prefix (e.g. /projects/foo vs /projects/foobar).
const resolvedTarget = path.resolve(targetPath);
const projectPrefix = resolvedProjectPath.endsWith(path.sep)
? resolvedProjectPath
: resolvedProjectPath + path.sep;
if (!resolvedTarget.startsWith(projectPrefix) && resolvedTarget !== resolvedProjectPath) {
res.status(400).json({
success: false,
error: 'Path traversal detected',
});
return;
}
}
// Determine parent relative path
let parentRelativePath: string | null = null;
if (currentRelativePath) {
const parent = path.dirname(currentRelativePath);
parentRelativePath = parent === '.' ? '' : parent;
}
try {
const stat = await secureFs.stat(targetPath);
if (!stat.isDirectory()) {
res.status(400).json({ success: false, error: 'Path is not a directory' });
return;
}
// Read directory contents
const dirEntries = await secureFs.readdir(targetPath, { withFileTypes: true });
// Filter and map entries
const entries: ProjectFileEntry[] = dirEntries
.filter((entry) => {
// Skip hidden directories (build artifacts, etc.)
if (entry.isDirectory() && HIDDEN_DIRECTORIES.has(entry.name)) {
return false;
}
// Skip entries starting with . (hidden files) except common config files
// We keep hidden files visible since users often need .env, .eslintrc, etc.
return true;
})
.map((entry) => {
const entryRelativePath = currentRelativePath
? path.posix.join(currentRelativePath.replace(/\\/g, '/'), entry.name)
: entry.name;
return {
name: entry.name,
relativePath: entryRelativePath,
isDirectory: entry.isDirectory(),
isFile: entry.isFile(),
};
})
// Sort: directories first, then files, alphabetically within each group
.sort((a, b) => {
if (a.isDirectory !== b.isDirectory) {
return a.isDirectory ? -1 : 1;
}
return a.name.localeCompare(b.name);
});
res.json({
success: true,
currentRelativePath,
parentRelativePath,
entries,
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to read directory';
const isPermissionError = errorMessage.includes('EPERM') || errorMessage.includes('EACCES');
if (isPermissionError) {
res.json({
success: true,
currentRelativePath,
parentRelativePath,
entries: [],
warning: 'Permission denied - unable to read this directory',
});
} else {
res.status(400).json({
success: false,
error: errorMessage,
});
}
}
} catch (error) {
if (error instanceof PathNotAllowedError) {
res.status(403).json({ success: false, error: getErrorMessage(error) });
return;
}
logError(error, 'Browse project files failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,99 @@
/**
* POST /copy endpoint - Copy file or directory to a new location
*/
import type { Request, Response } from 'express';
import * as secureFs from '../../../lib/secure-fs.js';
import path from 'path';
import { PathNotAllowedError } from '@automaker/platform';
import { mkdirSafe } from '@automaker/utils';
import { getErrorMessage, logError } from '../common.js';
/**
* Recursively copy a directory and its contents
*/
async function copyDirectoryRecursive(src: string, dest: string): Promise<void> {
await mkdirSafe(dest);
const entries = await secureFs.readdir(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
await copyDirectoryRecursive(srcPath, destPath);
} else {
await secureFs.copyFile(srcPath, destPath);
}
}
}
export function createCopyHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { sourcePath, destinationPath, overwrite } = req.body as {
sourcePath: string;
destinationPath: string;
overwrite?: boolean;
};
if (!sourcePath || !destinationPath) {
res
.status(400)
.json({ success: false, error: 'sourcePath and destinationPath are required' });
return;
}
// Prevent copying a folder into itself or its own descendant (infinite recursion)
const resolvedSrc = path.resolve(sourcePath);
const resolvedDest = path.resolve(destinationPath);
if (resolvedDest === resolvedSrc || resolvedDest.startsWith(resolvedSrc + path.sep)) {
res.status(400).json({
success: false,
error: 'Cannot copy a folder into itself or one of its own descendants',
});
return;
}
// Check if destination already exists
try {
await secureFs.stat(destinationPath);
// Destination exists
if (!overwrite) {
res.status(409).json({
success: false,
error: 'Destination already exists',
exists: true,
});
return;
}
// If overwrite is true, remove the existing destination first to avoid merging
await secureFs.rm(destinationPath, { recursive: true });
} catch {
// Destination doesn't exist - good to proceed
}
// Ensure parent directory exists
await mkdirSafe(path.dirname(path.resolve(destinationPath)));
// Check if source is a directory
const stats = await secureFs.stat(sourcePath);
if (stats.isDirectory()) {
await copyDirectoryRecursive(sourcePath, destinationPath);
} else {
await secureFs.copyFile(sourcePath, destinationPath);
}
res.json({ success: true });
} catch (error) {
if (error instanceof PathNotAllowedError) {
res.status(403).json({ success: false, error: getErrorMessage(error) });
return;
}
logError(error, 'Copy file failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,142 @@
/**
* POST /download endpoint - Download a file, or GET /download for streaming
* For folders, creates a zip archive on the fly
*/
import type { Request, Response } from 'express';
import * as secureFs from '../../../lib/secure-fs.js';
import path from 'path';
import { PathNotAllowedError } from '@automaker/platform';
import { getErrorMessage, logError } from '../common.js';
import { createReadStream } from 'fs';
import { execFile } from 'child_process';
import { promisify } from 'util';
import { tmpdir } from 'os';
const execFileAsync = promisify(execFile);
/**
* Get total size of a directory recursively
*/
async function getDirectorySize(dirPath: string): Promise<number> {
let totalSize = 0;
const entries = await secureFs.readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
const entryPath = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
totalSize += await getDirectorySize(entryPath);
} else {
const stats = await secureFs.stat(entryPath);
totalSize += Number(stats.size);
}
}
return totalSize;
}
export function createDownloadHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { filePath } = req.body as { filePath: string };
if (!filePath) {
res.status(400).json({ success: false, error: 'filePath is required' });
return;
}
const stats = await secureFs.stat(filePath);
const fileName = path.basename(filePath);
if (stats.isDirectory()) {
// For directories, create a zip archive
const dirSize = await getDirectorySize(filePath);
const MAX_DIR_SIZE = 100 * 1024 * 1024; // 100MB limit
if (dirSize > MAX_DIR_SIZE) {
res.status(413).json({
success: false,
error: `Directory is too large to download (${(dirSize / (1024 * 1024)).toFixed(1)}MB). Maximum size is ${MAX_DIR_SIZE / (1024 * 1024)}MB.`,
size: dirSize,
});
return;
}
// Create a temporary zip file
const zipFileName = `${fileName}.zip`;
const tmpZipPath = path.join(tmpdir(), `automaker-download-${Date.now()}-${zipFileName}`);
try {
// Use system zip command (available on macOS and Linux)
// Use execFile to avoid shell injection via user-provided paths
await execFileAsync('zip', ['-r', tmpZipPath, fileName], {
cwd: path.dirname(filePath),
maxBuffer: 50 * 1024 * 1024,
});
const zipStats = await secureFs.stat(tmpZipPath);
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', `attachment; filename="${zipFileName}"`);
res.setHeader('Content-Length', zipStats.size.toString());
res.setHeader('X-Directory-Size', dirSize.toString());
const stream = createReadStream(tmpZipPath);
stream.pipe(res);
stream.on('end', async () => {
// Cleanup temp file
try {
await secureFs.rm(tmpZipPath);
} catch {
// Ignore cleanup errors
}
});
stream.on('error', async (err) => {
logError(err, 'Download stream error');
try {
await secureFs.rm(tmpZipPath);
} catch {
// Ignore cleanup errors
}
if (!res.headersSent) {
res.status(500).json({ success: false, error: 'Stream error during download' });
}
});
} catch (zipError) {
// Cleanup on zip failure
try {
await secureFs.rm(tmpZipPath);
} catch {
// Ignore
}
throw zipError;
}
} else {
// For individual files, stream directly
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
res.setHeader('Content-Length', stats.size.toString());
const stream = createReadStream(filePath);
stream.pipe(res);
stream.on('error', (err) => {
logError(err, 'Download stream error');
if (!res.headersSent) {
res.status(500).json({ success: false, error: 'Stream error during download' });
}
});
}
} catch (error) {
if (error instanceof PathNotAllowedError) {
res.status(403).json({ success: false, error: getErrorMessage(error) });
return;
}
logError(error, 'Download failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -35,9 +35,9 @@ export function createMkdirHandler() {
error: 'Path exists and is not a directory',
});
return;
} catch (statError: any) {
} catch (statError: unknown) {
// ENOENT means path doesn't exist - we should create it
if (statError.code !== 'ENOENT') {
if ((statError as NodeJS.ErrnoException).code !== 'ENOENT') {
// Some other error (could be ELOOP in parent path)
throw statError;
}
@@ -47,7 +47,7 @@ export function createMkdirHandler() {
await secureFs.mkdir(resolvedPath, { recursive: true });
res.json({ success: true });
} catch (error: any) {
} catch (error: unknown) {
// Path not allowed - return 403 Forbidden
if (error instanceof PathNotAllowedError) {
res.status(403).json({ success: false, error: getErrorMessage(error) });
@@ -55,7 +55,7 @@ export function createMkdirHandler() {
}
// Handle ELOOP specifically
if (error.code === 'ELOOP') {
if ((error as NodeJS.ErrnoException).code === 'ELOOP') {
logError(error, 'Create directory failed - symlink loop detected');
res.status(400).json({
success: false,

View File

@@ -0,0 +1,79 @@
/**
* POST /move endpoint - Move (rename) file or directory to a new location
*/
import type { Request, Response } from 'express';
import * as secureFs from '../../../lib/secure-fs.js';
import path from 'path';
import { PathNotAllowedError } from '@automaker/platform';
import { mkdirSafe } from '@automaker/utils';
import { getErrorMessage, logError } from '../common.js';
export function createMoveHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { sourcePath, destinationPath, overwrite } = req.body as {
sourcePath: string;
destinationPath: string;
overwrite?: boolean;
};
if (!sourcePath || !destinationPath) {
res
.status(400)
.json({ success: false, error: 'sourcePath and destinationPath are required' });
return;
}
// Prevent moving to same location or into its own descendant
const resolvedSrc = path.resolve(sourcePath);
const resolvedDest = path.resolve(destinationPath);
if (resolvedDest === resolvedSrc) {
// No-op: source and destination are the same
res.json({ success: true });
return;
}
if (resolvedDest.startsWith(resolvedSrc + path.sep)) {
res.status(400).json({
success: false,
error: 'Cannot move a folder into one of its own descendants',
});
return;
}
// Check if destination already exists
try {
await secureFs.stat(destinationPath);
// Destination exists
if (!overwrite) {
res.status(409).json({
success: false,
error: 'Destination already exists',
exists: true,
});
return;
}
// If overwrite is true, remove the existing destination first
await secureFs.rm(destinationPath, { recursive: true });
} catch {
// Destination doesn't exist - good to proceed
}
// Ensure parent directory exists
await mkdirSafe(path.dirname(path.resolve(destinationPath)));
// Use rename for the move operation
await secureFs.rename(sourcePath, destinationPath);
res.json({ success: true });
} catch (error) {
if (error instanceof PathNotAllowedError) {
res.status(403).json({ success: false, error: getErrorMessage(error) });
return;
}
logError(error, 'Move file failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -3,16 +3,29 @@
*/
import type { Request, Response } from 'express';
import path from 'path';
import * as secureFs from '../../../lib/secure-fs.js';
import { PathNotAllowedError } from '@automaker/platform';
import { getErrorMessage, logError } from '../common.js';
// Optional files that are expected to not exist in new projects
// Don't log ENOENT errors for these to reduce noise
const OPTIONAL_FILES = ['categories.json', 'app_spec.txt'];
const OPTIONAL_FILES = ['categories.json', 'app_spec.txt', 'context-metadata.json'];
function isOptionalFile(filePath: string): boolean {
return OPTIONAL_FILES.some((optionalFile) => filePath.endsWith(optionalFile));
const basename = path.basename(filePath);
if (OPTIONAL_FILES.some((optionalFile) => basename === optionalFile)) {
return true;
}
// Context and memory files may not exist yet during create/delete or test races
if (filePath.includes('.automaker/context/') || filePath.includes('.automaker/memory/')) {
const name = path.basename(filePath);
const lower = name.toLowerCase();
if (lower.endsWith('.md') || lower.endsWith('.txt') || lower.endsWith('.markdown')) {
return true;
}
}
return false;
}
function isENOENT(error: unknown): boolean {
@@ -39,12 +52,14 @@ export function createReadHandler() {
return;
}
// Don't log ENOENT errors for optional files (expected to be missing in new projects)
const shouldLog = !(isENOENT(error) && isOptionalFile(req.body?.filePath || ''));
if (shouldLog) {
const filePath = req.body?.filePath || '';
const optionalMissing = isENOENT(error) && isOptionalFile(filePath);
if (!optionalMissing) {
logError(error, 'Read file failed');
}
res.status(500).json({ success: false, error: getErrorMessage(error) });
// Return 404 for missing optional files so clients can handle "not found"
const status = optionalMissing ? 404 : 500;
res.status(status).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -10,7 +10,11 @@ import { getErrorMessage, logError } from '../common.js';
export function createResolveDirectoryHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { directoryName, sampleFiles, fileCount } = req.body as {
const {
directoryName,
sampleFiles,
fileCount: _fileCount,
} = req.body as {
directoryName: string;
sampleFiles?: string[];
fileCount?: number;

View File

@@ -11,10 +11,9 @@ import { getBoardDir } from '@automaker/platform';
export function createSaveBoardBackgroundHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { data, filename, mimeType, projectPath } = req.body as {
const { data, filename, projectPath } = req.body as {
data: string;
filename: string;
mimeType: string;
projectPath: string;
};

View File

@@ -12,10 +12,9 @@ import { sanitizeFilename } from '@automaker/utils';
export function createSaveImageHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { data, filename, mimeType, projectPath } = req.body as {
const { data, filename, projectPath } = req.body as {
data: string;
filename: string;
mimeType: string;
projectPath: string;
};

View File

@@ -35,6 +35,16 @@ export function createStatHandler() {
return;
}
// File or directory does not exist - return 404 so UI can handle missing paths
const code =
error && typeof error === 'object' && 'code' in error
? (error as { code: string }).code
: '';
if (code === 'ENOENT') {
res.status(404).json({ success: false, error: 'File or directory not found' });
return;
}
logError(error, 'Get file stats failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}

View File

@@ -5,7 +5,7 @@
import type { Request, Response } from 'express';
import * as secureFs from '../../../lib/secure-fs.js';
import path from 'path';
import { isPathAllowed, PathNotAllowedError, getAllowedRootDirectory } from '@automaker/platform';
import { isPathAllowed, getAllowedRootDirectory } from '@automaker/platform';
import { getErrorMessage, logError } from '../common.js';
export function createValidatePathHandler() {

View File

@@ -24,7 +24,9 @@ export function createWriteHandler() {
// Ensure parent directory exists (symlink-safe)
await mkdirSafe(path.dirname(path.resolve(filePath)));
await secureFs.writeFile(filePath, content, 'utf-8');
// Default content to empty string if undefined/null to prevent writing
// "undefined" as literal text (e.g. when content field is missing from request)
await secureFs.writeFile(filePath, content ?? '', 'utf-8');
res.json({ success: true });
} catch (error) {

View File

@@ -0,0 +1,66 @@
import { Router, Request, Response } from 'express';
import { GeminiProvider } from '../../providers/gemini-provider.js';
import { GeminiUsageService } from '../../services/gemini-usage-service.js';
import { createLogger } from '@automaker/utils';
import type { EventEmitter } from '../../lib/events.js';
const logger = createLogger('Gemini');
export function createGeminiRoutes(
usageService: GeminiUsageService,
_events: EventEmitter
): Router {
const router = Router();
// Get current usage/quota data from Google Cloud API
router.get('/usage', async (_req: Request, res: Response) => {
try {
const usageData = await usageService.fetchUsageData();
res.json(usageData);
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
logger.error('Error fetching Gemini usage:', error);
// Return error in a format the UI expects
res.status(200).json({
authenticated: false,
authMethod: 'none',
usedPercent: 0,
remainingPercent: 100,
lastUpdated: new Date().toISOString(),
error: `Failed to fetch Gemini usage: ${message}`,
});
}
});
// Check if Gemini is available
router.get('/status', async (_req: Request, res: Response) => {
try {
const provider = new GeminiProvider();
const status = await provider.detectInstallation();
// Derive authMethod from typed InstallationStatus fields
const authMethod = status.authenticated
? status.hasApiKey
? 'api_key'
: 'cli_login'
: 'none';
res.json({
success: true,
installed: status.installed,
version: status.version || null,
path: status.path || null,
authenticated: status.authenticated || false,
authMethod,
hasCredentialsFile: false,
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ success: false, error: message });
}
});
return router;
}

View File

@@ -6,12 +6,22 @@ import { Router } from 'express';
import { validatePathParams } from '../../middleware/validate-paths.js';
import { createDiffsHandler } from './routes/diffs.js';
import { createFileDiffHandler } from './routes/file-diff.js';
import { createStageFilesHandler } from './routes/stage-files.js';
import { createDetailsHandler } from './routes/details.js';
import { createEnhancedStatusHandler } from './routes/enhanced-status.js';
export function createGitRoutes(): Router {
const router = Router();
router.post('/diffs', validatePathParams('projectPath'), createDiffsHandler());
router.post('/file-diff', validatePathParams('projectPath', 'filePath'), createFileDiffHandler());
router.post(
'/stage-files',
validatePathParams('projectPath', 'files[]'),
createStageFilesHandler()
);
router.post('/details', validatePathParams('projectPath', 'filePath?'), createDetailsHandler());
router.post('/enhanced-status', validatePathParams('projectPath'), createEnhancedStatusHandler());
return router;
}

View File

@@ -0,0 +1,248 @@
/**
* POST /details endpoint - Get detailed git info for a file or project
* Returns branch, last commit info, diff stats, and conflict status
*/
import type { Request, Response } from 'express';
import { exec, execFile } from 'child_process';
import { promisify } from 'util';
import * as secureFs from '../../../lib/secure-fs.js';
import { getErrorMessage, logError } from '../common.js';
const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
interface GitFileDetails {
branch: string;
lastCommitHash: string;
lastCommitMessage: string;
lastCommitAuthor: string;
lastCommitTimestamp: string;
linesAdded: number;
linesRemoved: number;
isConflicted: boolean;
isStaged: boolean;
isUnstaged: boolean;
statusLabel: string;
}
export function createDetailsHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, filePath } = req.body as {
projectPath: string;
filePath?: string;
};
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath required' });
return;
}
try {
// Get current branch
const { stdout: branchRaw } = await execAsync('git rev-parse --abbrev-ref HEAD', {
cwd: projectPath,
});
const branch = branchRaw.trim();
if (!filePath) {
// Project-level details - just return branch info
res.json({
success: true,
details: { branch },
});
return;
}
// Get last commit info for this file
let lastCommitHash = '';
let lastCommitMessage = '';
let lastCommitAuthor = '';
let lastCommitTimestamp = '';
try {
const { stdout: logOutput } = await execFileAsync(
'git',
['log', '-1', '--format=%H|%s|%an|%aI', '--', filePath],
{ cwd: projectPath }
);
if (logOutput.trim()) {
const parts = logOutput.trim().split('|');
lastCommitHash = parts[0] || '';
lastCommitMessage = parts[1] || '';
lastCommitAuthor = parts[2] || '';
lastCommitTimestamp = parts[3] || '';
}
} catch {
// File may not have any commits yet
}
// Get diff stats (lines added/removed)
let linesAdded = 0;
let linesRemoved = 0;
try {
// Check if file is untracked first
const { stdout: statusLine } = await execFileAsync(
'git',
['status', '--porcelain', '--', filePath],
{ cwd: projectPath }
);
if (statusLine.trim().startsWith('??')) {
// Untracked file - count all lines as added using Node.js instead of shell
try {
const fileContent = (await secureFs.readFile(filePath, 'utf-8')).toString();
const lines = fileContent.split('\n');
// Don't count trailing empty line from final newline
linesAdded =
lines.length > 0 && lines[lines.length - 1] === ''
? lines.length - 1
: lines.length;
} catch {
// Ignore
}
} else {
const { stdout: diffStatRaw } = await execFileAsync(
'git',
['diff', '--numstat', 'HEAD', '--', filePath],
{ cwd: projectPath }
);
if (diffStatRaw.trim()) {
const parts = diffStatRaw.trim().split('\t');
linesAdded = parseInt(parts[0], 10) || 0;
linesRemoved = parseInt(parts[1], 10) || 0;
}
// Also check staged diff stats
const { stdout: stagedDiffStatRaw } = await execFileAsync(
'git',
['diff', '--numstat', '--cached', '--', filePath],
{ cwd: projectPath }
);
if (stagedDiffStatRaw.trim()) {
const parts = stagedDiffStatRaw.trim().split('\t');
linesAdded += parseInt(parts[0], 10) || 0;
linesRemoved += parseInt(parts[1], 10) || 0;
}
}
} catch {
// Diff might not be available
}
// Get conflict and staging status
let isConflicted = false;
let isStaged = false;
let isUnstaged = false;
let statusLabel = '';
try {
const { stdout: statusOutput } = await execFileAsync(
'git',
['status', '--porcelain', '--', filePath],
{ cwd: projectPath }
);
if (statusOutput.trim()) {
const indexStatus = statusOutput[0];
const workTreeStatus = statusOutput[1];
// Check for conflicts (both modified, unmerged states)
if (
indexStatus === 'U' ||
workTreeStatus === 'U' ||
(indexStatus === 'A' && workTreeStatus === 'A') ||
(indexStatus === 'D' && workTreeStatus === 'D')
) {
isConflicted = true;
statusLabel = 'Conflicted';
} else {
// Staged changes (index has a status)
if (indexStatus !== ' ' && indexStatus !== '?') {
isStaged = true;
}
// Unstaged changes (work tree has a status)
if (workTreeStatus !== ' ' && workTreeStatus !== '?') {
isUnstaged = true;
}
// Build status label
if (isStaged && isUnstaged) {
statusLabel = 'Staged + Modified';
} else if (isStaged) {
statusLabel = 'Staged';
} else {
const statusChar = workTreeStatus !== ' ' ? workTreeStatus : indexStatus;
switch (statusChar) {
case 'M':
statusLabel = 'Modified';
break;
case 'A':
statusLabel = 'Added';
break;
case 'D':
statusLabel = 'Deleted';
break;
case 'R':
statusLabel = 'Renamed';
break;
case 'C':
statusLabel = 'Copied';
break;
case '?':
statusLabel = 'Untracked';
break;
default:
statusLabel = statusChar || '';
}
}
}
}
} catch {
// Status might not be available
}
const details: GitFileDetails = {
branch,
lastCommitHash,
lastCommitMessage,
lastCommitAuthor,
lastCommitTimestamp,
linesAdded,
linesRemoved,
isConflicted,
isStaged,
isUnstaged,
statusLabel,
};
res.json({ success: true, details });
} catch (innerError) {
logError(innerError, 'Git details failed');
res.json({
success: true,
details: {
branch: '',
lastCommitHash: '',
lastCommitMessage: '',
lastCommitAuthor: '',
lastCommitTimestamp: '',
linesAdded: 0,
linesRemoved: 0,
isConflicted: false,
isStaged: false,
isUnstaged: false,
statusLabel: '',
},
});
}
} catch (error) {
logError(error, 'Get git details failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -23,6 +23,7 @@ export function createDiffsHandler() {
diff: result.diff,
files: result.files,
hasChanges: result.hasChanges,
...(result.mergeState ? { mergeState: result.mergeState } : {}),
});
} catch (innerError) {
logError(innerError, 'Git diff failed');

View File

@@ -0,0 +1,176 @@
/**
* POST /enhanced-status endpoint - Get enhanced git status with diff stats per file
* Returns per-file status with lines added/removed and staged/unstaged differentiation
*/
import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { promisify } from 'util';
import { getErrorMessage, logError } from '../common.js';
const execAsync = promisify(exec);
interface EnhancedFileStatus {
path: string;
indexStatus: string;
workTreeStatus: string;
isConflicted: boolean;
isStaged: boolean;
isUnstaged: boolean;
linesAdded: number;
linesRemoved: number;
statusLabel: string;
}
function getStatusLabel(indexStatus: string, workTreeStatus: string): string {
// Check for conflicts
if (
indexStatus === 'U' ||
workTreeStatus === 'U' ||
(indexStatus === 'A' && workTreeStatus === 'A') ||
(indexStatus === 'D' && workTreeStatus === 'D')
) {
return 'Conflicted';
}
const hasStaged = indexStatus !== ' ' && indexStatus !== '?';
const hasUnstaged = workTreeStatus !== ' ' && workTreeStatus !== '?';
if (hasStaged && hasUnstaged) return 'Staged + Modified';
if (hasStaged) return 'Staged';
const statusChar = workTreeStatus !== ' ' ? workTreeStatus : indexStatus;
switch (statusChar) {
case 'M':
return 'Modified';
case 'A':
return 'Added';
case 'D':
return 'Deleted';
case 'R':
return 'Renamed';
case 'C':
return 'Copied';
case '?':
return 'Untracked';
default:
return statusChar || '';
}
}
export function createEnhancedStatusHandler() {
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;
}
try {
// Get current branch
const { stdout: branchRaw } = await execAsync('git rev-parse --abbrev-ref HEAD', {
cwd: projectPath,
});
const branch = branchRaw.trim();
// Get porcelain status for all files
const { stdout: statusOutput } = await execAsync('git status --porcelain', {
cwd: projectPath,
});
// Get diff numstat for working tree changes
let workTreeStats: Record<string, { added: number; removed: number }> = {};
try {
const { stdout: numstatRaw } = await execAsync('git diff --numstat', {
cwd: projectPath,
maxBuffer: 10 * 1024 * 1024,
});
for (const line of numstatRaw.trim().split('\n').filter(Boolean)) {
const parts = line.split('\t');
if (parts.length >= 3) {
const added = parseInt(parts[0], 10) || 0;
const removed = parseInt(parts[1], 10) || 0;
workTreeStats[parts[2]] = { added, removed };
}
}
} catch {
// Ignore
}
// Get diff numstat for staged changes
let stagedStats: Record<string, { added: number; removed: number }> = {};
try {
const { stdout: stagedNumstatRaw } = await execAsync('git diff --numstat --cached', {
cwd: projectPath,
maxBuffer: 10 * 1024 * 1024,
});
for (const line of stagedNumstatRaw.trim().split('\n').filter(Boolean)) {
const parts = line.split('\t');
if (parts.length >= 3) {
const added = parseInt(parts[0], 10) || 0;
const removed = parseInt(parts[1], 10) || 0;
stagedStats[parts[2]] = { added, removed };
}
}
} catch {
// Ignore
}
// Parse status and build enhanced file list
const files: EnhancedFileStatus[] = [];
for (const line of statusOutput.split('\n').filter(Boolean)) {
if (line.length < 4) continue;
const indexStatus = line[0];
const workTreeStatus = line[1];
const filePath = line.substring(3).trim();
// Handle renamed files (format: "R old -> new")
const actualPath = filePath.includes(' -> ')
? filePath.split(' -> ')[1].trim()
: filePath;
const isConflicted =
indexStatus === 'U' ||
workTreeStatus === 'U' ||
(indexStatus === 'A' && workTreeStatus === 'A') ||
(indexStatus === 'D' && workTreeStatus === 'D');
const isStaged = indexStatus !== ' ' && indexStatus !== '?';
const isUnstaged = workTreeStatus !== ' ' && workTreeStatus !== '?';
// Combine diff stats from both working tree and staged
const wtStats = workTreeStats[actualPath] || { added: 0, removed: 0 };
const stStats = stagedStats[actualPath] || { added: 0, removed: 0 };
files.push({
path: actualPath,
indexStatus,
workTreeStatus,
isConflicted,
isStaged,
isUnstaged,
linesAdded: wtStats.added + stStats.added,
linesRemoved: wtStats.removed + stStats.removed,
statusLabel: getStatusLabel(indexStatus, workTreeStatus),
});
}
res.json({
success: true,
branch,
files,
});
} catch (innerError) {
logError(innerError, 'Git enhanced status failed');
res.json({ success: true, branch: '', files: [] });
}
} catch (error) {
logError(error, 'Get enhanced status failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

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