127 Commits

Author SHA1 Message Date
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
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
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
Web Dev Cody
61d43106c8 Merge pull request #769 from AutoMaker-Org/v0.14.0rc
V0.14.0rc
2026-02-13 13:47:05 -05:00
webdevcody
9c304eeec3 chore: update project status and licensing information
- Replace the Automaker License Agreement with a simplified project status section in the README, indicating that the project is no longer actively maintained.
- Update the LICENSE file to reflect the new MIT License.
- Add license information to package.json for clarity.
2026-02-13 13:46:32 -05:00
DhanushSantosh
3563dd55da fix: recover interrupted features and allow branch switch with untracked files 2026-02-08 16:40:55 +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
DhanushSantosh
d06d25b1b5 fix: enhance commit messages and sidebar scroll visibility
Fix #689: Improve auto-generated commit message quality
- Add generateCommitMessage() method that includes description summary
- Include first 5 lines of feature description (up to 300 chars)
- Add git diff stats to provide file change context
- Commit messages now reflect the actual scope of work performed
- Maintains backward compatibility with fallback for missing features

Fix #601: Improve scroll indicator visibility on small screens
- Enhanced scroll indicator with gradient fade effect
- Show indicator on both expanded and collapsed sidebar states
- Added "Scroll" text label for better discoverability
- More prominent brand-colored chevron with animation
- Prevents Project Settings from being hidden on smaller laptop screens

Both fixes improve user experience without breaking existing functionality.
Test results: All 1,421 server tests pass
2026-02-05 10:52:59 +05:30
DhanushSantosh
84570842d3 fix: resolve three critical bugs from GitHub issue tracker
Fix #684: Prevent Windows reserved filename creation
- Add sanitizeFilename() utility to detect and prefix Windows reserved names
  (NUL, CON, PRN, AUX, COM1-9, LPT1-9)
- Apply sanitization to save-image route to prevent "nul" file creation
- Add 23 comprehensive tests for filename sanitization edge cases

Fix #576: Detect actual dev server port from output
- Parse stdout/stderr for real server URLs (Vite, Next.js, generic formats)
- Update server URL when detected instead of using allocated PORT
- Emit dev-server:url-detected event for frontend updates
- Add 6 tests for URL detection patterns

Fix #193: Commit only feature-specific changes
- Change from 'git add -A' to branch-aware file staging
- Use git diff to find files changed on feature branch only
- Prevent committing unrelated changes from other features
- Maintain backward compatibility with main branch workflow

All fixes include comprehensive tests and maintain backward compatibility.
Test results: 1,968 tests passed (547 package + 1,421 server tests)
2026-02-05 10:42:56 +05:30
DhanushSantosh
63cae19aec feat(enhance): add project context to prompt enhancements 2026-02-04 10:16:35 +05:30
DhanushSantosh
c9e721bda7 fix(cursor): detect Windows native CLI installs 2026-02-04 10:16:19 +05:30
DhanushSantosh
d4b7a0c57d feat(ui): add board refresh and stale-state polling 2026-02-04 10:16:11 +05:30
DhanushSantosh
0b6e84ec6e fix(ui): wrap long branch names in create PR dialog 2026-02-04 10:15:58 +05:30
DhanushSantosh
e9c2afcc02 docs: warn about Docker UID/GID mismatch 2026-02-04 09:50:48 +05:30
Dhanush Santosh
88864ad6bc feature/custom terminal configs (#717)
* feat(terminal): Add core infrastructure for custom terminal configurations

- Add TerminalConfig types to settings schema (global & project-specific)
- Create RC generator with hex-to-xterm-256 color mapping
- Create RC file manager for .automaker/terminal/ directory
- Add terminal theme color data (40 themes) to platform package
- Integrate terminal config injection into TerminalService
- Support bash, zsh, and sh with proper env var injection (BASH_ENV, ZDOTDIR, ENV)
- Add onThemeChange hook for theme synchronization

Part of custom terminal configurations feature implementation.

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

* feat(terminal): Wire terminal service with settings service

- Pass SettingsService to TerminalService constructor
- Initialize terminal service with settings service dependency
- Enable terminal config injection to work with actual settings

This completes Steps 1-4 of the terminal configuration plan:
- RC Generator (color mapping, prompt formats)
- RC File Manager (file I/O, atomic writes)
- Settings Schema (GlobalSettings + ProjectSettings)
- Terminal Service Integration (env var injection)

Next steps: Settings UI and theme change hooks.

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

* feat(terminal): Add Settings UI and theme change synchronization

Complete Steps 5 & 6 of terminal configuration implementation:

Settings UI Components:
- Add PromptPreview component with live theme-aware rendering
- Add TerminalConfigSection with comprehensive controls:
  * Enable/disable toggle with confirmation dialog
  * Custom prompt toggle
  * Prompt format selector (4 formats)
  * Git branch/status toggles
  * Custom aliases textarea
  * Custom env vars key-value editor with validation
  * Info box explaining behavior
- Integrate into existing TerminalSection

Theme Change Hook:
- Add theme detection in update-global settings route
- Regenerate RC files for all projects when theme changes
- Skip projects with terminal config disabled
- Error handling with per-project logging
- Inject terminal service with settings service dependency

This completes the full terminal configuration feature:
✓ RC Generator (color mapping, prompts)
✓ RC File Manager (file I/O, versioning)
✓ Settings Schema (types, defaults)
✓ Terminal Service Integration (env vars, PTY spawn)
✓ Settings UI (comprehensive controls, preview)
✓ Theme Synchronization (automatic RC regeneration)

New terminals will use custom prompts matching app theme.
Existing terminals unaffected. User RC files preserved.

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

* fix(terminal): Add error handling and explicit field mapping for terminal config

- Add try-catch block to handleToggleEnabled
- Explicitly set all required terminalConfig fields
- Add console logging for debugging
- Show error toast if update fails
- Include rcFileVersion: 1 in config object

This should fix the issue where the toggle doesn't enable after
clicking OK in the confirmation dialog.

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

* fix(terminal): Use React Query mutation hook for settings updates

The issue was that `updateGlobalSettings` doesn't exist in the app store.
The correct pattern is to use the `useUpdateGlobalSettings` hook from
use-settings-mutations.ts, which is a React Query mutation.

Changes:
- Import useUpdateGlobalSettings from mutations hook
- Use mutation.mutate() instead of direct function call
- Add proper onSuccess/onError callbacks
- Remove async/await pattern (React Query handles this)

This fixes the toggle not enabling after clicking OK in the confirmation dialog.

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

* fix(terminal): Use React Query hook for globalSettings instead of store

The root cause: Component was reading globalSettings from the app store,
which doesn't update reactively when the mutation completes.

Solution: Use useGlobalSettings() React Query hook which:
- Automatically refetches when the mutation invalidates the cache
- Triggers re-render with updated data
- Makes the toggle reflect the new state

Now the flow is:
1. User clicks toggle → confirmation dialog
2. Click OK → mutation.mutate() called
3. Mutation succeeds → invalidates queryKeys.settings.global()
4. Query refetches → component re-renders with new globalSettings
5. Toggle shows enabled state ✓

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

* debug(terminal): Add detailed logging for terminal config application

Add logging to track:
- When terminal config check happens
- CWD being used
- Global and project enabled states
- Effective enabled state

This will help diagnose why RC files aren't being generated
when opening terminals in Automaker.

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

* Fix terminal rc updates and bash rcfile loading

* feat(terminal): add banner on shell start

* feat(terminal): colorize banner per theme

* chore(terminal): bump rc version for banner colors

* feat(terminal): match banner colors to launcher

* feat(terminal): add prompt customization controls

* feat: integrate oh-my-posh prompt themes

* fix: resolve oh-my-posh theme path

* fix: correct oh-my-posh config invocation

* docs: add terminal theme screenshot

* fix: address review feedback and stabilize e2e test

* ui: split terminal config into separate card

* fix: enable cross-platform Warp terminal detection

- Remove macOS-only platform restriction for Warp
- Add Linux CLI alias 'warp-terminal' (primary on Linux)
- Add CLI launch handler using --cwd flag
- Fixes issue where Warp was not detected on Linux systems

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

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 20:34:33 +05:30
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
141 changed files with 9493 additions and 1763 deletions

4
.gitignore vendored
View File

@@ -90,6 +90,8 @@ 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
@@ -98,3 +100,5 @@ data/
# GSD planning docs (local-only)
.planning/
.mcp.json
.planning

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

158
LICENSE
View File

@@ -1,141 +1,27 @@
AUTOMAKER LICENSE AGREEMENT
## Project Status
This License Agreement ("Agreement") is entered into between you ("Licensee") and the copyright holders of Automaker ("Licensor"). By using, copying, modifying, downloading, cloning, or distributing the Software (as defined below), you agree to be bound by the terms of this Agreement.
1. DEFINITIONS
"Software" means the Automaker software, including all source code, object code, documentation, and related materials.
"Generated Files" means files created by the Software during normal operation to store internal state, configuration, or working data, including but not limited to app_spec.txt, feature.json, and similar files generated by the Software. Generated Files are not considered part of the Software for the purposes of this license and are not subject to the restrictions herein.
"Derivative Work" means any work that is based on, derived from, or incorporates the Software or any substantial portion of it, including but not limited to modifications, forks, adaptations, translations, or any altered version of the Software.
"Monetization" means any activity that generates revenue, income, or commercial benefit from the Software itself or any Derivative Work, including but not limited to:
- Reselling, redistributing, or sublicensing the Software, any Derivative Work, or any substantial portion thereof
- Including the Software, any Derivative Work, or substantial portions thereof in a product or service that you sell or distribute
- Offering the Software, any Derivative Work, or substantial portions thereof as a standalone product or service for sale
- Hosting the Software or any Derivative Work as a service (whether free or paid) for use by others, including cloud hosting, Software-as-a-Service (SaaS), or any other form of hosted access for third parties
- Extracting, reselling, redistributing, or sublicensing any prompts, context, or other instructional content bundled within the Software
- Creating, distributing, or selling modified versions, forks, or Derivative Works of the Software
Monetization does NOT include:
- Using the Software internally within your organization, regardless of whether your organization is for-profit
- Using the Software to build products or services that generate revenue, as long as you are not reselling or redistributing the Software itself
- Using the Software to provide services for which fees are charged, as long as the Software itself is not being resold or redistributed
- Hosting the Software anywhere for personal use by a single developer, as long as the Software is not made accessible to others
"Core Contributors" means the following individuals who are granted perpetual, royalty-free licenses:
- Cody Seibert (webdevcody)
- SuperComboGamer (SCG)
- Kacper Lachowicz (Shironex, Shirone)
- Ben Scott (trueheads)
2. GRANT OF LICENSE
Subject to the terms and conditions of this Agreement, Licensor hereby grants to Licensee a non-exclusive, non-transferable license to use, copy, modify, and distribute the Software, provided that:
a) Licensee may freely clone, install, and use the Software locally or within an organization for the purpose of building, developing, and maintaining other products, software, or services. There are no restrictions on the products you build _using_ the Software.
b) Licensee may run the Software on personal or organizational infrastructure for internal use.
c) Core Contributors are each individually granted a perpetual, worldwide, royalty-free, non-exclusive license to use, copy, modify, distribute, and sublicense the Software for any purpose, including Monetization, without payment of any fees or royalties. Each Core Contributor may exercise these rights independently and does not require permission, consent, or approval from any other Core Contributor to Monetize the Software in any way they see fit.
d) Commercial licenses for the Software may be discussed and issued to external parties or companies seeking to use the Software for financial gain or Monetization purposes. Core Contributors already have full rights under section 2(c) and do not require commercial licenses. Any commercial license issued to external parties shall require a unanimous vote by all Core Contributors and shall be granted in writing and signed by all Core Contributors.
e) The list of individuals defined as "Core Contributors" in Section 1 shall be amended to reflect any revocation or reinstatement of status made under this section.
3. RESTRICTIONS
Licensee may NOT:
- Engage in any Monetization of the Software or any Derivative Work without explicit written permission from all Core Contributors
- Resell, redistribute, or sublicense the Software, any Derivative Work, or any substantial portion thereof
- Create, distribute, or sell modified versions, forks, or Derivative Works of the Software for any commercial purpose
- Include the Software, any Derivative Work, or substantial portions thereof in a product or service that you sell or distribute
- Offer the Software, any Derivative Work, or substantial portions thereof as a standalone product or service for sale
- Extract, resell, redistribute, or sublicense any prompts, context, or other instructional content bundled within the Software
- Host the Software or any Derivative Work as a service (whether free or paid) for use by others (except Core Contributors)
- Remove or alter any copyright notices or license terms
- Use the Software in any manner that violates applicable laws or regulations
Licensee MAY:
- Use the Software internally within their organization (commercial or non-profit)
- Use the Software to build other commercial products (products that do NOT contain the Software or Derivative Works)
- Modify the Software for internal use within their organization (commercial or non-profit)
4. CORE CONTRIBUTOR STATUS MANAGEMENT
a) Core Contributor status may be revoked indefinitely by the remaining Core Contributors if:
- A Core Contributor cannot be reached for a period of one (1) month through reasonable means of communication (including but not limited to email, Discord, GitHub, or other project communication channels)
- AND the Core Contributor has not contributed to the project during that one-month period. For purposes of this section, "contributed" means at least one of the following activities:
- Discussing the Software through project communication channels
- Committing code changes to the project repository
- Submitting bug fixes or patches
- Participating in project-related discussions or decision-making
b) Revocation of Core Contributor status requires a unanimous vote by all other Core Contributors (excluding the Core Contributor whose status is being considered for revocation).
c) Upon revocation of Core Contributor status, the individual shall no longer be considered a Core Contributor and shall lose the rights granted under section 2(c) of this Agreement. However, any Contributions made prior to revocation shall remain subject to the terms of section 5 (CONTRIBUTIONS AND RIGHTS ASSIGNMENT).
d) A revoked Core Contributor may be reinstated to Core Contributor status with a unanimous vote by all current Core Contributors. Upon reinstatement, the individual shall regain all rights granted under section 2(c) of this Agreement.
5. CONTRIBUTIONS AND RIGHTS ASSIGNMENT
By submitting, pushing, or contributing any code, documentation, pull requests, issues, or other materials ("Contributions") to the Automaker project, you agree to the following terms without reservation:
a) **Full Ownership Transfer & Rights Grant:** You hereby assign to the Core Contributors all right, title, and interest in and to your Contributions, including all copyrights, patents, and other intellectual property rights. If such assignment is not effective under applicable law, you grant the Core Contributors an unrestricted, perpetual, worldwide, non-exclusive, royalty-free, fully paid-up, irrevocable, sublicensable, and transferable license to use, reproduce, modify, adapt, publish, translate, create derivative works from, distribute, perform, display, and otherwise exploit your Contributions in any manner they see fit, including for any commercial purpose or Monetization.
b) **No Take-Backs:** You understand and agree that this grant of rights is irrevocable ("no take-backs"). You cannot revoke, rescind, or terminate this grant of rights once your Contribution has been submitted.
c) **Waiver of Moral Rights:** You waive any "moral rights" or other rights with respect to attribution of authorship or integrity of materials regarding your Contributions that you may have under any applicable law.
d) **Right to Contribute:** You represent and warrant that you are the original author of the Contributions, or that you have sufficient rights to grant the rights conveyed by this section, and that your Contributions do not infringe upon the rights of any third party.
6. TERMINATION
This license will terminate automatically if Licensee breaches any term of this Agreement. Upon termination, Licensee must immediately cease all use of the Software and destroy all copies in their possession.
7. HIGH RISK DISCLAIMER AND LIMITATION OF LIABILITY
a) **AI RISKS:** THE SOFTWARE UTILIZES ARTIFICIAL INTELLIGENCE TO GENERATE CODE, EXECUTE COMMANDS, AND INTERACT WITH YOUR FILE SYSTEM. YOU ACKNOWLEDGE THAT AI SYSTEMS CAN BE UNPREDICTABLE, MAY GENERATE INCORRECT, INSECURE, OR DESTRUCTIVE CODE, AND MAY TAKE ACTIONS THAT COULD DAMAGE YOUR SYSTEM, FILES, OR HARDWARE.
b) **USE AT YOUR OWN RISK:** YOU AGREE THAT YOUR USE OF THE SOFTWARE IS SOLELY AT YOUR OWN RISK. THE CORE CONTRIBUTORS AND LICENSOR DO NOT GUARANTEE THAT THE SOFTWARE OR ANY CODE GENERATED BY IT WILL BE SAFE, BUG-FREE, OR FUNCTIONAL.
c) **NO WARRANTY:** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT.
d) **LIMITATION OF LIABILITY:** IN NO EVENT SHALL THE CORE CONTRIBUTORS, LICENSORS, OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE, INCLUDING BUT NOT LIMITED TO:
- DAMAGE TO HARDWARE OR COMPUTER SYSTEMS
- DATA LOSS OR CORRUPTION
- GENERATION OF BAD, VULNERABLE, OR MALICIOUS CODE
- FINANCIAL LOSSES
- BUSINESS INTERRUPTION
8. LICENSE AMENDMENTS
Any amendment, modification, or update to this License Agreement must be agreed upon unanimously by all Core Contributors. No changes to this Agreement shall be effective unless all Core Contributors have provided their written consent or approval through a unanimous vote.
9. CONTACT
For inquiries regarding this license or permissions for Monetization, please contact the Core Contributors through the official project channels:
- Agentic Jumpstart Discord: https://discord.gg/JUDWZDN3VT
- Website: https://automaker.app
- Email: automakerapp@gmail.com
Any permission for Monetization requires the unanimous written consent of all Core Contributors.
10. GOVERNING LAW
This Agreement shall be governed by and construed in accordance with the laws of the State of Tennessee, USA, without regard to conflict of law principles.
By using the Software, you acknowledge that you have read this Agreement, understand it, and agree to be bound by its terms and conditions.
**This project is no longer actively maintained.** The codebase is provided as-is for those who wish to use, study, or fork it. No bug fixes, security updates, or new features are being developed. Community contributions may still be accepted, but there is no guarantee of review or merge.
---
MIT License
Copyright (c) 2025 Automaker Core Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -288,6 +288,31 @@ services:
**Note:** The Claude CLI config must be writable (do not use `:ro` flag) as the CLI writes debug files.
> **⚠️ Important: Linux/WSL Users**
>
> The container runs as UID 1001 by default. If your host user has a different UID (common on Linux/WSL where the first user is UID 1000), you must create a `.env` file to match your host user:
>
> ```bash
> # Check your UID/GID
> id -u # outputs your UID (e.g., 1000)
> id -g # outputs your GID (e.g., 1000)
> ```
>
> Create a `.env` file in the automaker directory:
>
> ```
> UID=1000
> GID=1000
> ```
>
> Then rebuild the images:
>
> ```bash
> docker compose build
> ```
>
> Without this, files written by the container will be inaccessible to your host user.
##### GitHub CLI Authentication (For Git Push/PR Operations)
To enable git push and GitHub CLI operations inside the container:
@@ -338,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)
@@ -644,26 +705,10 @@ Join the **Agentic Jumpstart** Discord to connect with other builders exploring
👉 [Agentic Jumpstart Discord](https://discord.gg/jjem7aEDKU)
## Project Status
**This project is no longer actively maintained.** The codebase is provided as-is for those who wish to use, study, or fork it. No bug fixes, security updates, or new features are being developed. Community contributions may still be accepted, but there is no guarantee of review or merge.
## License
This project is licensed under the **Automaker License Agreement**. See [LICENSE](LICENSE) for the full text.
**Summary of Terms:**
- **Allowed:**
- **Build Anything:** You can clone and use Automaker locally or in your organization to build ANY product (commercial or free).
- **Internal Use:** You can use it internally within your company (commercial or non-profit) without restriction.
- **Modify:** You can modify the code for internal use within your organization (commercial or non-profit).
- **Restricted (The "No Monetization of the Tool" Rule):**
- **No Resale:** You cannot resell Automaker itself.
- **No SaaS:** You cannot host Automaker as a service for others.
- **No Monetizing Mods:** You cannot distribute modified versions of Automaker for money.
- **Liability:**
- **Use at Own Risk:** This tool uses AI. We are **NOT** responsible if it breaks your computer, deletes your files, or generates bad code. You assume all risk.
- **Contributing:**
- By contributing to this repository, you grant the Core Contributors full, irrevocable rights to your code (copyright assignment).
**Core Contributors** (Cody Seibert (webdevcody), SuperComboGamer (SCG), Kacper Lachowicz (Shironex, Shirone), and Ben Scott (trueheads)) are granted perpetual, royalty-free licenses for any use, including monetization.
This project is licensed under the **MIT License**. See [LICENSE](LICENSE) for the full text.

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

@@ -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",
@@ -34,7 +34,7 @@
"@automaker/utils": "1.0.0",
"@github/copilot-sdk": "^0.1.16",
"@modelcontextprotocol/sdk": "1.25.2",
"@openai/codex-sdk": "^0.77.0",
"@openai/codex-sdk": "^0.98.0",
"cookie-parser": "1.4.7",
"cors": "2.8.5",
"dotenv": "17.2.3",
@@ -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

@@ -121,21 +121,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 +181,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 +194,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 +232,7 @@ const BOX_CONTENT_WIDTH = 67;
${w3}
${w4}
${w5}
${w6}
${w6}${pathsCheckedInfo}
║ ║
╚═════════════════════════════════════════════════════════════════════╝
`);
@@ -305,24 +368,61 @@ 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}`);
}
// Apply request logging setting (default true if not set)
const enableRequestLog = settings.enableRequestLogging ?? true;
setRequestLoggingEnabled(enableRequestLog);
logger.info(`HTTP request logging: ${enableRequestLog ? 'enabled' : 'disabled'}`);
globalSettings = await settingsService.getGlobalSettings();
} catch (err) {
logger.warn('Failed to load logging settings, using defaults');
logger.warn('Failed to load global settings, using defaults');
}
// Apply logging settings from saved settings
if (globalSettings) {
try {
if (
globalSettings.serverLogLevel &&
LOG_LEVEL_MAP[globalSettings.serverLogLevel] !== undefined
) {
setLogLevel(LOG_LEVEL_MAP[globalSettings.serverLogLevel]);
logger.info(`Server log level set to: ${globalSettings.serverLogLevel}`);
}
// Apply request logging setting (default true if not set)
const enableRequestLog = globalSettings.enableRequestLogging ?? true;
setRequestLoggingEnabled(enableRequestLog);
logger.info(`HTTP request logging: ${enableRequestLog ? 'enabled' : 'disabled'}`);
} catch (err) {
logger.warn('Failed to apply logging settings, using defaults');
}
}
await agentService.initialize();
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');
}
}
} 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);
@@ -392,7 +492,7 @@ const server = createServer(app);
// WebSocket servers using noServer mode for proper multi-path support
const wss = new WebSocketServer({ noServer: true });
const terminalWss = new WebSocketServer({ noServer: true });
const terminalService = getTerminalService();
const terminalService = getTerminalService(settingsService);
/**
* Authenticate WebSocket upgrade requests

View File

@@ -253,11 +253,27 @@ function buildMcpOptions(config: CreateSdkOptionsConfig): McpOptions {
/**
* Build thinking options for SDK configuration.
* Converts ThinkingLevel to maxThinkingTokens for the Claude SDK.
* For adaptive thinking (Opus 4.6), omits maxThinkingTokens to let the model
* decide its own reasoning depth.
*
* @param thinkingLevel - The thinking level to convert
* @returns Object with maxThinkingTokens if thinking is enabled
* @returns Object with maxThinkingTokens if thinking is enabled with a budget
*/
function buildThinkingOptions(thinkingLevel?: ThinkingLevel): Partial<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}`

View File

@@ -0,0 +1,25 @@
/**
* Terminal Theme Data - Re-export terminal themes from platform package
*
* This module re-exports terminal theme data for use in the server.
*/
import { terminalThemeColors, getTerminalThemeColors as getThemeColors } from '@automaker/platform';
import type { ThemeMode } from '@automaker/types';
import type { TerminalTheme } from '@automaker/platform';
/**
* Get terminal theme colors for a given theme mode
*/
export function getTerminalThemeColors(theme: ThemeMode): TerminalTheme {
return getThemeColors(theme);
}
/**
* Get all terminal themes
*/
export function getAllTerminalThemes(): Record<ThemeMode, TerminalTheme> {
return terminalThemeColors;
}
export default terminalThemeColors;

View File

@@ -204,7 +204,7 @@ export class ClaudeProvider extends BaseProvider {
model,
cwd,
systemPrompt,
maxTurns = 20,
maxTurns = 100,
allowedTools,
abortController,
conversationHistory,
@@ -219,8 +219,11 @@ export class ClaudeProvider extends BaseProvider {
// claudeCompatibleProvider takes precedence over claudeApiProfile
const providerConfig = claudeCompatibleProvider || claudeApiProfile;
// Convert thinking level to token budget
const maxThinkingTokens = getThinkingTokenBudget(thinkingLevel);
// Build thinking configuration
// Adaptive thinking (Opus 4.6): don't set maxThinkingTokens, model uses adaptive by default
// Manual thinking (Haiku/Sonnet): use budget_tokens
const maxThinkingTokens =
thinkingLevel === 'adaptive' ? undefined : getThinkingTokenBudget(thinkingLevel);
// Build Claude SDK options
const sdkOptions: Options = {
@@ -349,13 +352,13 @@ export class ClaudeProvider extends BaseProvider {
getAvailableModels(): ModelDefinition[] {
const models = [
{
id: 'claude-opus-4-5-20251101',
name: 'Claude Opus 4.5',
modelString: 'claude-opus-4-5-20251101',
id: 'claude-opus-4-6',
name: 'Claude Opus 4.6',
modelString: 'claude-opus-4-6',
provider: 'anthropic',
description: 'Most capable Claude model',
description: 'Most capable Claude model with adaptive thinking',
contextWindow: 200000,
maxOutputTokens: 16000,
maxOutputTokens: 128000,
supportsVision: true,
supportsTools: true,
tier: 'premium' as const,

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,25 @@ export const CODEX_MODELS: ModelDefinition[] = [
default: true,
hasReasoning: true,
},
{
id: CODEX_MODEL_MAP.gpt52Codex,
name: 'GPT-5.2-Codex',
modelString: CODEX_MODEL_MAP.gpt52Codex,
provider: 'openai',
description: 'Frontier agentic coding model.',
contextWindow: CONTEXT_WINDOW_256K,
maxOutputTokens: MAX_OUTPUT_32K,
supportsVision: true,
supportsTools: true,
tier: 'premium' as const,
hasReasoning: true,
},
{
id: CODEX_MODEL_MAP.gpt51CodexMax,
name: 'GPT-5.1-Codex-Max',
modelString: CODEX_MODEL_MAP.gpt51CodexMax,
provider: 'openai',
description: 'Optimized for long-horizon, agentic coding tasks in Codex.',
description: 'Codex-optimized flagship for deep and fast reasoning.',
contextWindow: CONTEXT_WINDOW_256K,
maxOutputTokens: MAX_OUTPUT_32K,
supportsVision: true,
@@ -51,7 +63,7 @@ export const CODEX_MODELS: ModelDefinition[] = [
name: 'GPT-5.1-Codex-Mini',
modelString: CODEX_MODEL_MAP.gpt51CodexMini,
provider: 'openai',
description: 'Smaller, more cost-effective version for faster workflows.',
description: 'Optimized for codex. Cheaper, faster, but less capable.',
contextWindow: CONTEXT_WINDOW_128K,
maxOutputTokens: MAX_OUTPUT_16K,
supportsVision: true,
@@ -66,7 +78,7 @@ export const CODEX_MODELS: ModelDefinition[] = [
name: 'GPT-5.2',
modelString: CODEX_MODEL_MAP.gpt52,
provider: 'openai',
description: 'Best general agentic model for tasks across industries and domains.',
description: 'Latest frontier model with improvements across knowledge, reasoning and coding.',
contextWindow: CONTEXT_WINDOW_256K,
maxOutputTokens: MAX_OUTPUT_32K,
supportsVision: true,

View File

@@ -14,6 +14,7 @@ import { execSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { findCliInWsl, isWslAvailable } from '@automaker/platform';
import {
CliProvider,
type CliSpawnConfig,
@@ -286,15 +287,113 @@ export class CursorProvider extends CliProvider {
getSpawnConfig(): CliSpawnConfig {
return {
windowsStrategy: 'wsl', // cursor-agent requires WSL on Windows
windowsStrategy: 'direct',
commonPaths: {
linux: [
path.join(os.homedir(), '.local/bin/cursor-agent'), // Primary symlink location
'/usr/local/bin/cursor-agent',
],
darwin: [path.join(os.homedir(), '.local/bin/cursor-agent'), '/usr/local/bin/cursor-agent'],
// Windows paths are not used - we check for WSL installation instead
win32: [],
win32: [
path.join(
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
'Programs',
'Cursor',
'resources',
'app',
'bin',
'cursor-agent.exe'
),
path.join(
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
'Programs',
'Cursor',
'resources',
'app',
'bin',
'cursor-agent.cmd'
),
path.join(
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
'Programs',
'Cursor',
'resources',
'app',
'bin',
'cursor.exe'
),
path.join(
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
'Programs',
'Cursor',
'cursor.exe'
),
path.join(
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
'Programs',
'cursor',
'resources',
'app',
'bin',
'cursor-agent.exe'
),
path.join(
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
'Programs',
'cursor',
'resources',
'app',
'bin',
'cursor-agent.cmd'
),
path.join(
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
'Programs',
'cursor',
'resources',
'app',
'bin',
'cursor.exe'
),
path.join(
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
'Programs',
'cursor',
'cursor.exe'
),
path.join(
process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
'npm',
'cursor-agent.cmd'
),
path.join(
process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
'npm',
'cursor.cmd'
),
path.join(
process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
'.npm-global',
'bin',
'cursor-agent.cmd'
),
path.join(
process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
'.npm-global',
'bin',
'cursor.cmd'
),
path.join(
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
'pnpm',
'cursor-agent.cmd'
),
path.join(
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
'pnpm',
'cursor.cmd'
),
],
},
};
}
@@ -487,6 +586,92 @@ export class CursorProvider extends CliProvider {
* 2. Cursor IDE with 'cursor agent' subcommand support
*/
protected detectCli(): CliDetectionResult {
if (process.platform === 'win32') {
const findInPath = (command: string): string | null => {
try {
const result = execSync(`where ${command}`, {
encoding: 'utf8',
timeout: 5000,
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true,
})
.trim()
.split(/\r?\n/)[0];
if (result && fs.existsSync(result)) {
return result;
}
} catch {
// Not in PATH
}
return null;
};
const isCursorAgentBinary = (cliPath: string) =>
cliPath.toLowerCase().includes('cursor-agent');
const supportsCursorAgentSubcommand = (cliPath: string) => {
try {
execSync(`"${cliPath}" agent --version`, {
encoding: 'utf8',
timeout: 5000,
stdio: 'pipe',
windowsHide: true,
});
return true;
} catch {
return false;
}
};
const pathResult = findInPath('cursor-agent') || findInPath('cursor');
if (pathResult) {
if (isCursorAgentBinary(pathResult) || supportsCursorAgentSubcommand(pathResult)) {
return {
cliPath: pathResult,
useWsl: false,
strategy: pathResult.toLowerCase().endsWith('.cmd') ? 'cmd' : 'direct',
};
}
}
const config = this.getSpawnConfig();
for (const candidate of config.commonPaths.win32 || []) {
const resolved = candidate;
if (!fs.existsSync(resolved)) {
continue;
}
if (isCursorAgentBinary(resolved) || supportsCursorAgentSubcommand(resolved)) {
return {
cliPath: resolved,
useWsl: false,
strategy: resolved.toLowerCase().endsWith('.cmd') ? 'cmd' : 'direct',
};
}
}
const wslLogger = (msg: string) => logger.debug(msg);
if (isWslAvailable({ logger: wslLogger })) {
const wslResult = findCliInWsl('cursor-agent', { logger: wslLogger });
if (wslResult) {
logger.debug(
`Using cursor-agent via WSL (${wslResult.distribution || 'default'}): ${wslResult.wslPath}`
);
return {
cliPath: 'wsl.exe',
useWsl: true,
wslCliPath: wslResult.wslPath,
wslDistribution: wslResult.distribution,
strategy: 'wsl',
};
}
}
logger.debug('cursor-agent not found on Windows');
return { cliPath: null, useWsl: false, strategy: 'direct' };
}
// First try standard detection (PATH, common paths, WSL)
const result = super.detectCli();
if (result.cliPath) {
@@ -495,7 +680,7 @@ export class CursorProvider extends CliProvider {
// Cursor-specific: Check versions directory for any installed version
// This handles cases where cursor-agent is installed but not in PATH
if (process.platform !== 'win32' && fs.existsSync(CursorProvider.VERSIONS_DIR)) {
if (fs.existsSync(CursorProvider.VERSIONS_DIR)) {
try {
const versions = fs
.readdirSync(CursorProvider.VERSIONS_DIR)
@@ -521,33 +706,31 @@ export class CursorProvider extends CliProvider {
// If cursor-agent not found, try to find 'cursor' IDE and use 'cursor agent' subcommand
// The Cursor IDE includes the agent as a subcommand: cursor agent
if (process.platform !== 'win32') {
const cursorPaths = [
'/usr/bin/cursor',
'/usr/local/bin/cursor',
path.join(os.homedir(), '.local/bin/cursor'),
'/opt/cursor/cursor',
];
const cursorPaths = [
'/usr/bin/cursor',
'/usr/local/bin/cursor',
path.join(os.homedir(), '.local/bin/cursor'),
'/opt/cursor/cursor',
];
for (const cursorPath of cursorPaths) {
if (fs.existsSync(cursorPath)) {
// Verify cursor agent subcommand works
try {
execSync(`"${cursorPath}" agent --version`, {
encoding: 'utf8',
timeout: 5000,
stdio: 'pipe',
});
logger.debug(`Using cursor agent via Cursor IDE: ${cursorPath}`);
// Return cursor path but we'll use 'cursor agent' subcommand
return {
cliPath: cursorPath,
useWsl: false,
strategy: 'native',
};
} catch {
// cursor agent subcommand doesn't work, try next path
}
for (const cursorPath of cursorPaths) {
if (fs.existsSync(cursorPath)) {
// Verify cursor agent subcommand works
try {
execSync(`"${cursorPath}" agent --version`, {
encoding: 'utf8',
timeout: 5000,
stdio: 'pipe',
});
logger.debug(`Using cursor agent via Cursor IDE: ${cursorPath}`);
// Return cursor path but we'll use 'cursor agent' subcommand
return {
cliPath: cursorPath,
useWsl: false,
strategy: 'native',
};
} catch {
// cursor agent subcommand doesn't work, try next path
}
}
}

View File

@@ -103,7 +103,7 @@ export class ProviderFactory {
/**
* Get the appropriate provider for a given model ID
*
* @param modelId Model identifier (e.g., "claude-opus-4-5-20251101", "cursor-gpt-4o", "cursor-auto")
* @param modelId Model identifier (e.g., "claude-opus-4-6", "cursor-gpt-4o", "cursor-auto")
* @param options Optional settings
* @param options.throwOnDisconnected Throw error if provider is disconnected (default: true)
* @returns Provider instance for the model

View File

@@ -21,6 +21,7 @@ import { createFollowUpFeatureHandler } from './routes/follow-up-feature.js';
import { createCommitFeatureHandler } from './routes/commit-feature.js';
import { createApprovePlanHandler } from './routes/approve-plan.js';
import { createResumeInterruptedHandler } from './routes/resume-interrupted.js';
import { createReconcileHandler } from './routes/reconcile.js';
/**
* Create auto-mode routes.
@@ -81,6 +82,11 @@ export function createAutoModeRoutes(autoModeService: AutoModeServiceCompat): Ro
validatePathParams('projectPath'),
createResumeInterruptedHandler(autoModeService)
);
router.post(
'/reconcile',
validatePathParams('projectPath'),
createReconcileHandler(autoModeService)
);
return router;
}

View File

@@ -17,7 +17,7 @@ export function createApprovePlanHandler(autoModeService: AutoModeServiceCompat)
approved: boolean;
editedPlan?: string;
feedback?: string;
projectPath?: string;
projectPath: string;
};
if (!featureId) {
@@ -36,6 +36,14 @@ export function createApprovePlanHandler(autoModeService: AutoModeServiceCompat)
return;
}
if (!projectPath) {
res.status(400).json({
success: false,
error: 'projectPath is required',
});
return;
}
// Note: We no longer check hasPendingApproval here because resolvePlanApproval
// can handle recovery when pending approval is not in Map but feature has planSpec.status='generated'
// This supports cases where the server restarted while waiting for approval
@@ -48,7 +56,7 @@ export function createApprovePlanHandler(autoModeService: AutoModeServiceCompat)
// Resolve the pending approval (with recovery support)
const result = await autoModeService.resolvePlanApproval(
projectPath || '',
projectPath,
featureId,
approved,
editedPlan,

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

@@ -25,7 +25,7 @@ export function createStatusHandler(autoModeService: AutoModeServiceCompat) {
// Normalize branchName: undefined becomes null
const normalizedBranchName = branchName ?? null;
const projectStatus = autoModeService.getStatusForProject(
const projectStatus = await autoModeService.getStatusForProject(
projectPath,
normalizedBranchName
);

View File

@@ -10,14 +10,23 @@ import type { Request, Response } from 'express';
import { createLogger } from '@automaker/utils';
import { resolveModelString } from '@automaker/model-resolver';
import { CLAUDE_MODEL_MAP, type ThinkingLevel } from '@automaker/types';
import { getAppSpecPath } from '@automaker/platform';
import { simpleQuery } from '../../../providers/simple-query-service.js';
import type { SettingsService } from '../../../services/settings-service.js';
import { getPromptCustomization, getProviderByModelId } from '../../../lib/settings-helpers.js';
import { FeatureLoader } from '../../../services/feature-loader.js';
import * as secureFs from '../../../lib/secure-fs.js';
import {
buildUserPrompt,
isValidEnhancementMode,
type EnhancementMode,
} from '../../../lib/enhancement-prompts.js';
import {
extractTechnologyStack,
extractXmlElements,
extractXmlSection,
unescapeXml,
} from '../../../lib/xml-extractor.js';
const logger = createLogger('EnhancePrompt');
@@ -53,6 +62,66 @@ interface EnhanceErrorResponse {
error: string;
}
async function buildProjectContext(projectPath: string): Promise<string | null> {
const contextBlocks: string[] = [];
try {
const appSpecPath = getAppSpecPath(projectPath);
const specContent = (await secureFs.readFile(appSpecPath, 'utf-8')) as string;
const projectName = extractXmlSection(specContent, 'project_name');
const overview = extractXmlSection(specContent, 'overview');
const techStack = extractTechnologyStack(specContent);
const coreSection = extractXmlSection(specContent, 'core_capabilities');
const coreCapabilities = coreSection ? extractXmlElements(coreSection, 'capability') : [];
const summaryLines: string[] = [];
if (projectName) {
summaryLines.push(`Name: ${unescapeXml(projectName.trim())}`);
}
if (overview) {
summaryLines.push(`Overview: ${unescapeXml(overview.trim())}`);
}
if (techStack.length > 0) {
summaryLines.push(`Tech Stack: ${techStack.join(', ')}`);
}
if (coreCapabilities.length > 0) {
summaryLines.push(`Core Capabilities: ${coreCapabilities.slice(0, 10).join(', ')}`);
}
if (summaryLines.length > 0) {
contextBlocks.push(`PROJECT CONTEXT:\n${summaryLines.map((line) => `- ${line}`).join('\n')}`);
}
} catch (error) {
logger.debug('No app_spec.txt context available for enhancement', error);
}
try {
const featureLoader = new FeatureLoader();
const features = await featureLoader.getAll(projectPath);
const featureTitles = features
.map((feature) => feature.title || feature.name || feature.id)
.filter((title) => Boolean(title));
if (featureTitles.length > 0) {
const listed = featureTitles.slice(0, 30).map((title) => `- ${title}`);
contextBlocks.push(
`EXISTING FEATURES (avoid duplicates):\n${listed.join('\n')}${
featureTitles.length > 30 ? '\n- ...' : ''
}`
);
}
} catch (error) {
logger.debug('Failed to load existing features for enhancement context', error);
}
if (contextBlocks.length === 0) {
return null;
}
return contextBlocks.join('\n\n');
}
/**
* Create the enhance request handler
*
@@ -122,6 +191,10 @@ export function createEnhanceHandler(
// Build the user prompt with few-shot examples
const userPrompt = buildUserPrompt(validMode, trimmedText, true);
const projectContext = projectPath ? await buildProjectContext(projectPath) : null;
if (projectContext) {
logger.debug('Including project context in enhancement prompt');
}
// Check if the model is a provider model (like "GLM-4.5-Air")
// If so, get the provider config and resolved Claude model
@@ -146,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}\n\n${userPrompt}`,
model: resolvedModel,
prompt: [systemPrompt, projectContext, userPrompt].filter(Boolean).join('\n\n'),
model: modelForApi,
cwd: process.cwd(), // Enhancement doesn't need a specific working directory
maxTurns: 1,
allowedTools: [],

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

@@ -33,18 +33,23 @@ export function createListHandler(
// We don't await this to keep the list response fast
// Note: detectOrphanedFeatures handles errors internally and always resolves
if (autoModeService) {
autoModeService.detectOrphanedFeatures(projectPath).then((orphanedFeatures) => {
if (orphanedFeatures.length > 0) {
logger.info(
`[ProjectLoad] Detected ${orphanedFeatures.length} orphaned feature(s) in ${projectPath}`
);
for (const { feature, missingBranch } of orphanedFeatures) {
autoModeService
.detectOrphanedFeatures(projectPath)
.then((orphanedFeatures) => {
if (orphanedFeatures.length > 0) {
logger.info(
`[ProjectLoad] Orphaned: ${feature.title || feature.id} - branch "${missingBranch}" no longer exists`
`[ProjectLoad] Detected ${orphanedFeatures.length} orphaned feature(s) in ${projectPath}`
);
for (const { feature, missingBranch } of orphanedFeatures) {
logger.info(
`[ProjectLoad] Orphaned: ${feature.title || feature.id} - branch "${missingBranch}" no longer exists`
);
}
}
}
});
})
.catch((error) => {
logger.warn(`[ProjectLoad] Orphan detection failed for ${projectPath}:`, error);
});
}
res.json({ success: true, features });

View File

@@ -40,23 +40,6 @@ export function createUpdateHandler(featureLoader: FeatureLoader) {
return;
}
// Check for duplicate title if title is being updated
if (updates.title && updates.title.trim()) {
const duplicate = await featureLoader.findDuplicateTitle(
projectPath,
updates.title,
featureId // Exclude the current feature from duplicate check
);
if (duplicate) {
res.status(409).json({
success: false,
error: `A feature with title "${updates.title}" already exists`,
duplicateFeatureId: duplicate.id,
});
return;
}
}
// Get the current feature to detect status changes
const currentFeature = await featureLoader.get(projectPath, featureId);
const previousStatus = currentFeature?.status as FeatureStatus | undefined;

View File

@@ -7,6 +7,7 @@ import * as secureFs from '../../../lib/secure-fs.js';
import path from 'path';
import { getErrorMessage, logError } from '../common.js';
import { getImagesDir } from '@automaker/platform';
import { sanitizeFilename } from '@automaker/utils';
export function createSaveImageHandler() {
return async (req: Request, res: Response): Promise<void> => {
@@ -39,7 +40,7 @@ export function createSaveImageHandler() {
// Generate unique filename with timestamp
const timestamp = Date.now();
const ext = path.extname(filename) || '.png';
const baseName = path.basename(filename, ext);
const baseName = sanitizeFilename(path.basename(filename, ext), 'image');
const uniqueFilename = `${baseName}-${timestamp}${ext}`;
const filePath = path.join(imagesDir, uniqueFilename);

View File

@@ -25,7 +25,7 @@ import {
isOpencodeModel,
supportsStructuredOutput,
} from '@automaker/types';
import { resolvePhaseModel } from '@automaker/model-resolver';
import { resolvePhaseModel, resolveModelString } from '@automaker/model-resolver';
import { extractJson } from '../../../lib/json-extractor.js';
import { writeValidation } from '../../../lib/validation-storage.js';
import { streamingQuery } from '../../../providers/simple-query-service.js';
@@ -188,8 +188,12 @@ ${basePrompt}`;
}
}
// Use provider resolved model if available, otherwise use original model
const effectiveModel = providerResolvedModel || (model as string);
// CRITICAL: For custom providers (GLM, MiniMax), pass the provider's model ID (e.g. "GLM-4.7")
// to the API, NOT the resolved Claude model - otherwise we get "model not found"
// For standard Claude models, resolve aliases (e.g., 'opus' -> 'claude-opus-4-20250514')
const effectiveModel = claudeCompatibleProvider
? (model as string)
: providerResolvedModel || resolveModelString(model as string);
logger.info(`Using model: ${effectiveModel}`);
// Use streamingQuery with event callbacks

View File

@@ -173,7 +173,7 @@ export function createOverviewHandler(
const totalFeatures = features.length;
// Get auto-mode status for this project (main worktree, branchName = null)
const autoModeStatus: ProjectAutoModeStatus = autoModeService.getStatusForProject(
const autoModeStatus: ProjectAutoModeStatus = await autoModeService.getStatusForProject(
projectRef.path,
null
);

View File

@@ -14,6 +14,7 @@ import type { GlobalSettings } from '../../../types/settings.js';
import { getErrorMessage, logError, logger } from '../common.js';
import { setLogLevel, LogLevel } from '@automaker/utils';
import { setRequestLoggingEnabled } from '../../../index.js';
import { getTerminalService } from '../../../services/terminal-service.js';
/**
* Map server log level string to LogLevel enum
@@ -57,6 +58,10 @@ export function createUpdateGlobalHandler(settingsService: SettingsService) {
}, localStorageMigrated=${(updates as any).localStorageMigrated ?? 'n/a'}`
);
// Get old settings to detect theme changes
const oldSettings = await settingsService.getGlobalSettings();
const oldTheme = oldSettings?.theme;
logger.info('[SERVER_SETTINGS_UPDATE] Calling updateGlobalSettings...');
const settings = await settingsService.updateGlobalSettings(updates);
logger.info(
@@ -64,6 +69,37 @@ export function createUpdateGlobalHandler(settingsService: SettingsService) {
settings.projects?.length ?? 0
);
// Handle theme change - regenerate terminal RC files for all projects
if ('theme' in updates && updates.theme && updates.theme !== oldTheme) {
const terminalService = getTerminalService(settingsService);
const newTheme = updates.theme;
logger.info(
`[TERMINAL_CONFIG] Theme changed from ${oldTheme} to ${newTheme}, regenerating RC files`
);
// Regenerate RC files for all projects with terminal config enabled
const projects = settings.projects || [];
for (const project of projects) {
try {
const projectSettings = await settingsService.getProjectSettings(project.path);
// Check if terminal config is enabled (global or project-specific)
const terminalConfigEnabled =
projectSettings.terminalConfig?.enabled !== false &&
settings.terminalConfig?.enabled === true;
if (terminalConfigEnabled) {
await terminalService.onThemeChange(project.path, newTheme);
logger.info(`[TERMINAL_CONFIG] Regenerated RC files for project: ${project.name}`);
}
} catch (error) {
logger.warn(
`[TERMINAL_CONFIG] Failed to regenerate RC files for project ${project.name}: ${error}`
);
}
}
}
// Apply server log level if it was updated
if ('serverLogLevel' in updates && updates.serverLogLevel) {
const level = LOG_LEVEL_MAP[updates.serverLogLevel];

View File

@@ -6,6 +6,7 @@
import type { Request, Response } from 'express';
import { query } from '@anthropic-ai/claude-agent-sdk';
import { createLogger } from '@automaker/utils';
import { getClaudeAuthIndicators } from '@automaker/platform';
import { getApiKey } from '../common.js';
import {
createSecureAuthEnv,
@@ -320,9 +321,28 @@ export function createVerifyClaudeAuthHandler() {
authMethod,
});
// Determine specific auth type for success messages
const effectiveAuthMethod = authMethod ?? 'api_key';
let authType: 'oauth' | 'api_key' | 'cli' | undefined;
if (authenticated) {
if (effectiveAuthMethod === 'api_key') {
authType = 'api_key';
} else if (effectiveAuthMethod === 'cli') {
// Check if CLI auth is via OAuth (Claude Code subscription) or generic CLI
try {
const indicators = await getClaudeAuthIndicators();
authType = indicators.credentials?.hasOAuthToken ? 'oauth' : 'cli';
} catch {
// Fall back to generic CLI if credential check fails
authType = 'cli';
}
}
}
res.json({
success: true,
authenticated,
authType,
error: errorMessage || undefined,
});
} catch (error) {

View File

@@ -101,7 +101,12 @@ export function createWorktreeRoutes(
requireValidWorktree,
createPullHandler()
);
router.post('/checkout-branch', requireValidWorktree, createCheckoutBranchHandler());
router.post(
'/checkout-branch',
validatePathParams('worktreePath'),
requireValidWorktree,
createCheckoutBranchHandler()
);
router.post(
'/list-branches',
validatePathParams('worktreePath'),

View File

@@ -2,15 +2,15 @@
* POST /checkout-branch endpoint - Create and checkout a new branch
*
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
* the requireValidWorktree middleware in index.ts
* the requireValidWorktree middleware in index.ts.
* Path validation (ALLOWED_ROOT_DIRECTORY) is handled by validatePathParams
* middleware in index.ts.
*/
import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { promisify } from 'util';
import { getErrorMessage, logError } from '../common.js';
const execAsync = promisify(exec);
import path from 'path';
import { stat } from 'fs/promises';
import { getErrorMessage, logError, isValidBranchName, execGitCommand } from '../common.js';
export function createCheckoutBranchHandler() {
return async (req: Request, res: Response): Promise<void> => {
@@ -36,27 +36,47 @@ export function createCheckoutBranchHandler() {
return;
}
// Validate branch name (basic validation)
const invalidChars = /[\s~^:?*\[\\]/;
if (invalidChars.test(branchName)) {
// Validate branch name using shared allowlist: /^[a-zA-Z0-9._\-/]+$/
if (!isValidBranchName(branchName)) {
res.status(400).json({
success: false,
error: 'Branch name contains invalid characters',
error:
'Invalid branch name. Must contain only letters, numbers, dots, dashes, underscores, or slashes.',
});
return;
}
// Get current branch for reference
const { stdout: currentBranchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', {
cwd: worktreePath,
});
// Resolve and validate worktreePath to prevent traversal attacks.
// The validatePathParams middleware checks against ALLOWED_ROOT_DIRECTORY,
// but we also resolve the path and verify it exists as a directory.
const resolvedPath = path.resolve(worktreePath);
try {
const stats = await stat(resolvedPath);
if (!stats.isDirectory()) {
res.status(400).json({
success: false,
error: 'worktreePath is not a directory',
});
return;
}
} catch {
res.status(400).json({
success: false,
error: 'worktreePath does not exist or is not accessible',
});
return;
}
// Get current branch for reference (using argument array to avoid shell injection)
const currentBranchOutput = await execGitCommand(
['rev-parse', '--abbrev-ref', 'HEAD'],
resolvedPath
);
const currentBranch = currentBranchOutput.trim();
// Check if branch already exists
try {
await execAsync(`git rev-parse --verify ${branchName}`, {
cwd: worktreePath,
});
await execGitCommand(['rev-parse', '--verify', branchName], resolvedPath);
// Branch exists
res.status(400).json({
success: false,
@@ -67,10 +87,8 @@ export function createCheckoutBranchHandler() {
// Branch doesn't exist, good to create
}
// Create and checkout the new branch
await execAsync(`git checkout -b ${branchName}`, {
cwd: worktreePath,
});
// Create and checkout the new branch (using argument array to avoid shell injection)
await execGitCommand(['checkout', '-b', branchName], resolvedPath);
res.json({
success: true,

View File

@@ -125,19 +125,14 @@ export function createOpenInEditorHandler() {
`Failed to open in editor, falling back to file manager: ${getErrorMessage(editorError)}`
);
try {
const result = await openInFileManager(worktreePath);
res.json({
success: true,
result: {
message: `Opened ${worktreePath} in ${result.editorName}`,
editorName: result.editorName,
},
});
} catch (fallbackError) {
// Both editor and file manager failed
throw fallbackError;
}
const result = await openInFileManager(worktreePath);
res.json({
success: true,
result: {
message: `Opened ${worktreePath} in ${result.editorName}`,
editorName: result.editorName,
},
});
}
} catch (error) {
logError(error, 'Open in editor failed');

View File

@@ -16,6 +16,21 @@ import { getErrorMessage, logError } from '../common.js';
const execAsync = promisify(exec);
function isUntrackedLine(line: string): boolean {
return line.startsWith('?? ');
}
function isExcludedWorktreeLine(line: string): boolean {
return line.includes('.worktrees/') || line.endsWith('.worktrees');
}
function isBlockingChangeLine(line: string): boolean {
if (!line.trim()) return false;
if (isExcludedWorktreeLine(line)) return false;
if (isUntrackedLine(line)) return false;
return true;
}
/**
* Check if there are uncommitted changes in the working directory
* Excludes .worktrees/ directory which is created by automaker
@@ -23,15 +38,7 @@ const execAsync = promisify(exec);
async function hasUncommittedChanges(cwd: string): Promise<boolean> {
try {
const { stdout } = await execAsync('git status --porcelain', { cwd });
const lines = stdout
.trim()
.split('\n')
.filter((line) => {
if (!line.trim()) return false;
// Exclude .worktrees/ directory (created by automaker)
if (line.includes('.worktrees/') || line.endsWith('.worktrees')) return false;
return true;
});
const lines = stdout.trim().split('\n').filter(isBlockingChangeLine);
return lines.length > 0;
} catch {
return false;
@@ -45,15 +52,7 @@ async function hasUncommittedChanges(cwd: string): Promise<boolean> {
async function getChangesSummary(cwd: string): Promise<string> {
try {
const { stdout } = await execAsync('git status --short', { cwd });
const lines = stdout
.trim()
.split('\n')
.filter((line) => {
if (!line.trim()) return false;
// Exclude .worktrees/ directory
if (line.includes('.worktrees/') || line.endsWith('.worktrees')) return false;
return true;
});
const lines = stdout.trim().split('\n').filter(isBlockingChangeLine);
if (lines.length === 0) return '';
if (lines.length <= 5) return lines.join(', ');
return `${lines.slice(0, 5).join(', ')} and ${lines.length - 5} more files`;

View File

@@ -126,9 +126,7 @@ export class AgentExecutor {
const appendRawEvent = (event: unknown): void => {
if (!enableRawOutput) return;
try {
rawOutputLines.push(
JSON.stringify({ timestamp: new Date().toISOString(), event }, null, 4)
);
rawOutputLines.push(JSON.stringify({ timestamp: new Date().toISOString(), event }));
if (rawWriteTimeout) clearTimeout(rawWriteTimeout);
rawWriteTimeout = setTimeout(async () => {
try {
@@ -333,7 +331,7 @@ export class AgentExecutor {
userFeedback
);
const taskStream = provider.executeQuery(
this.buildExecOpts(options, taskPrompt, Math.min(sdkOptions?.maxTurns || 100, 50))
this.buildExecOpts(options, taskPrompt, Math.min(sdkOptions?.maxTurns ?? 100, 100))
);
let taskOutput = '',
taskStartDetected = false,
@@ -552,7 +550,7 @@ export class AgentExecutor {
});
let revText = '';
for await (const msg of provider.executeQuery(
this.buildExecOpts(options, revPrompt, sdkOptions?.maxTurns || 100)
this.buildExecOpts(options, revPrompt, sdkOptions?.maxTurns ?? 100)
)) {
if (msg.type === 'assistant' && msg.message?.content)
for (const b of msg.message.content)
@@ -560,6 +558,7 @@ export class AgentExecutor {
revText += b.text || '';
this.eventBus.emitAutoModeEvent('auto_mode_progress', {
featureId,
branchName,
content: b.text,
});
}
@@ -638,6 +637,7 @@ export class AgentExecutor {
cwd: o.workDir,
allowedTools: o.sdkOptions?.allowedTools as string[] | undefined,
abortController: o.abortController,
thinkingLevel: o.thinkingLevel,
mcpServers:
o.mcpServers && Object.keys(o.mcpServers).length > 0
? (o.mcpServers as Record<string, { command: string }>)

View File

@@ -325,8 +325,9 @@ export class AgentService {
const effectiveThinkingLevel = thinkingLevel ?? session.thinkingLevel;
const effectiveReasoningEffort = reasoningEffort ?? session.reasoningEffort;
// When using a provider model, use the resolved Claude model (from mapsToClaudeModel)
// e.g., "GLM-4.5-Air" -> "claude-haiku-4-5"
// When using a custom provider (GLM, MiniMax), use resolved Claude model for SDK config
// (thinking level budgets, allowedTools) but we MUST pass the provider's model ID
// (e.g. "GLM-4.7") to the API - not "claude-sonnet-4-20250514" which causes "model not found"
const modelForSdk = providerResolvedModel || model;
const sessionModelForSdk = providerResolvedModel ? undefined : session.model;
@@ -387,10 +388,18 @@ export class AgentService {
}
// Get provider for this model (with prefix)
const provider = ProviderFactory.getProviderForModel(effectiveModel);
// When using custom provider (GLM, MiniMax), requestedModel routes to Claude provider
const modelForProvider = claudeCompatibleProvider
? (requestedModel ?? effectiveModel)
: effectiveModel;
const provider = ProviderFactory.getProviderForModel(modelForProvider);
// Strip provider prefix - providers should receive bare model IDs
const bareModel = stripProviderPrefix(effectiveModel);
// CRITICAL: For custom providers (GLM, MiniMax), pass the provider's model ID (e.g. "GLM-4.7")
// to the API, NOT the resolved Claude model - otherwise we get "model not found"
const bareModel: string = claudeCompatibleProvider
? (requestedModel ?? effectiveModel)
: stripProviderPrefix(effectiveModel);
// Build options for provider
const options: ExecuteOptions = {

View File

@@ -31,8 +31,16 @@ export interface ProjectAutoLoopState {
branchName: string | null;
}
/**
* Generate a unique key for a worktree auto-loop instance.
*
* When branchName is null, this represents the main worktree (uses '__main__' sentinel).
* The string 'main' is also normalized to '__main__' for consistency.
* Named branches always use their exact name.
*/
export function getWorktreeAutoLoopKey(projectPath: string, branchName: string | null): string {
return `${projectPath}::${(branchName === 'main' ? null : branchName) ?? '__main__'}`;
const normalizedBranch = branchName === 'main' ? null : branchName;
return `${projectPath}::${normalizedBranch ?? '__main__'}`;
}
export type ExecuteFeatureFn = (
@@ -404,11 +412,15 @@ export class AutoLoopCoordinator {
reject(new Error('Aborted'));
return;
}
const timeout = setTimeout(resolve, ms);
signal?.addEventListener('abort', () => {
const onAbort = () => {
clearTimeout(timeout);
reject(new Error('Aborted'));
});
};
const timeout = setTimeout(() => {
signal?.removeEventListener('abort', onAbort);
resolve();
}, ms);
signal?.addEventListener('abort', onAbort);
});
}
}

View File

@@ -13,6 +13,7 @@ import { GlobalAutoModeService } from './global-service.js';
import { AutoModeServiceFacade } from './facade.js';
import type { SettingsService } from '../settings-service.js';
import type { FeatureLoader } from '../feature-loader.js';
import type { ClaudeUsageService } from '../claude-usage-service.js';
import type { FacadeOptions, AutoModeStatus, RunningAgentInfo } from './types.js';
/**
@@ -22,11 +23,13 @@ import type { FacadeOptions, AutoModeStatus, RunningAgentInfo } from './types.js
export class AutoModeServiceCompat {
private readonly globalService: GlobalAutoModeService;
private readonly facadeOptions: FacadeOptions;
private readonly facadeCache = new Map<string, AutoModeServiceFacade>();
constructor(
events: EventEmitter,
settingsService: SettingsService | null,
featureLoader: FeatureLoader
featureLoader: FeatureLoader,
claudeUsageService?: ClaudeUsageService | null
) {
this.globalService = new GlobalAutoModeService(events, settingsService, featureLoader);
const sharedServices = this.globalService.getSharedServices();
@@ -36,6 +39,7 @@ export class AutoModeServiceCompat {
settingsService,
featureLoader,
sharedServices,
claudeUsageService: claudeUsageService ?? null,
};
}
@@ -47,10 +51,17 @@ export class AutoModeServiceCompat {
}
/**
* Create a facade for a specific project
* Get or create a facade for a specific project.
* Facades are cached by project path so that auto loop state
* (stored in the facade's AutoLoopCoordinator) persists across API calls.
*/
createFacade(projectPath: string): AutoModeServiceFacade {
return AutoModeServiceFacade.create(projectPath, this.facadeOptions);
let facade = this.facadeCache.get(projectPath);
if (!facade) {
facade = AutoModeServiceFacade.create(projectPath, this.facadeOptions);
this.facadeCache.set(projectPath, facade);
}
return facade;
}
// ===========================================================================
@@ -77,20 +88,24 @@ export class AutoModeServiceCompat {
return this.globalService.markAllRunningFeaturesInterrupted(reason);
}
async reconcileFeatureStates(projectPath: string): Promise<number> {
return this.globalService.reconcileFeatureStates(projectPath);
}
// ===========================================================================
// PER-PROJECT OPERATIONS (delegated to facades)
// ===========================================================================
getStatusForProject(
async getStatusForProject(
projectPath: string,
branchName: string | null = null
): {
): Promise<{
isAutoLoopRunning: boolean;
runningFeatures: string[];
runningCount: number;
maxConcurrency: number;
branchName: string | null;
} {
}> {
const facade = this.createFacade(projectPath);
return facade.getStatusForProject(branchName);
}

View File

@@ -17,10 +17,10 @@ import { promisify } from 'util';
import type { Feature, PlanningMode, ThinkingLevel } from '@automaker/types';
import { DEFAULT_MAX_CONCURRENCY, stripProviderPrefix } from '@automaker/types';
import { createLogger, loadContextFiles, classifyError } from '@automaker/utils';
import { getFeatureDir } from '@automaker/platform';
import { getFeatureDir, spawnProcess } from '@automaker/platform';
import * as secureFs from '../../lib/secure-fs.js';
import { validateWorkingDirectory } from '../../lib/sdk-options.js';
import { getPromptCustomization } from '../../lib/settings-helpers.js';
import { getPromptCustomization, getProviderByModelId } from '../../lib/settings-helpers.js';
import { TypedEventBus } from '../typed-event-bus.js';
import { ConcurrencyManager } from '../concurrency-manager.js';
import { WorktreeResolver } from '../worktree-resolver.js';
@@ -38,6 +38,7 @@ import type { SettingsService } from '../settings-service.js';
import type { EventEmitter } from '../../lib/events.js';
import type {
FacadeOptions,
FacadeError,
AutoModeStatus,
ProjectAutoModeStatus,
WorktreeCapacityInfo,
@@ -49,12 +50,21 @@ const execAsync = promisify(exec);
const logger = createLogger('AutoModeServiceFacade');
/**
* Generate a unique key for worktree-scoped auto loop state
* (mirrors the function in AutoModeService for status lookups)
* Execute git command with array arguments to prevent command injection.
*/
function getWorktreeAutoLoopKey(projectPath: string, branchName: string | null): string {
const normalizedBranch = branchName === 'main' ? null : branchName;
return `${projectPath}::${normalizedBranch ?? '__main__'}`;
async function execGitCommand(args: string[], cwd: string): Promise<string> {
const result = await spawnProcess({
command: 'git',
args,
cwd,
});
if (result.exitCode === 0) {
return result.stdout;
} else {
const errorMessage = result.stderr || `Git command failed with code ${result.exitCode}`;
throw new Error(errorMessage);
}
}
/**
@@ -80,6 +90,45 @@ export class AutoModeServiceFacade {
private readonly settingsService: SettingsService | null
) {}
/**
* Classify and log an error at the facade boundary.
* Emits an error event to the UI so failures are surfaced to the user.
*
* @param error - The caught error
* @param method - The facade method name where the error occurred
* @param featureId - Optional feature ID for context
* @returns The classified FacadeError for structured consumption
*/
private handleFacadeError(error: unknown, method: string, featureId?: string): FacadeError {
const errorInfo = classifyError(error);
// Log at the facade boundary for debugging
logger.error(
`[${method}] ${featureId ? `Feature ${featureId}: ` : ''}${errorInfo.message}`,
error
);
// Emit error event to UI unless it's an abort/cancellation
if (!errorInfo.isAbort && !errorInfo.isCancellation) {
this.eventBus.emitAutoModeEvent('auto_mode_error', {
featureId: featureId ?? null,
featureName: undefined,
branchName: null,
error: errorInfo.message,
errorType: errorInfo.type,
projectPath: this.projectPath,
});
}
return {
method,
errorType: errorInfo.type,
message: errorInfo.message,
featureId,
projectPath: this.projectPath,
};
}
/**
* Create a new AutoModeServiceFacade instance for a specific project.
*
@@ -134,9 +183,20 @@ export class AutoModeServiceFacade {
return prompt;
};
// Create placeholder callbacks - will be bound to facade methods after creation
// These use closures to capture the facade instance once created
// Create placeholder callbacks - will be bound to facade methods after creation.
// These use closures to capture the facade instance once created.
// INVARIANT: All callbacks passed to PipelineOrchestrator, AutoLoopCoordinator,
// and ExecutionService are invoked asynchronously (never during construction),
// so facadeInstance is guaranteed to be assigned before any callback runs.
let facadeInstance: AutoModeServiceFacade | null = null;
const getFacade = (): AutoModeServiceFacade => {
if (!facadeInstance) {
throw new Error(
'AutoModeServiceFacade not yet initialized — callback invoked during construction'
);
}
return facadeInstance;
};
// PipelineOrchestrator - runAgentFn is a stub; routes use AutoModeService directly
const pipelineOrchestrator = new PipelineOrchestrator(
@@ -153,7 +213,7 @@ export class AutoModeServiceFacade {
loadContextFiles,
buildFeaturePrompt,
(pPath, featureId, useWorktrees, _isAutoMode, _model, opts) =>
facadeInstance!.executeFeature(featureId, useWorktrees, false, undefined, opts),
getFacade().executeFeature(featureId, useWorktrees, false, undefined, opts),
// runAgentFn - delegates to AgentExecutor
async (
workDir: string,
@@ -169,6 +229,23 @@ export class AutoModeServiceFacade {
const provider = ProviderFactory.getProviderForModel(resolvedModel);
const effectiveBareModel = stripProviderPrefix(resolvedModel);
// Resolve custom provider (GLM, MiniMax, etc.) for baseUrl and credentials
let claudeCompatibleProvider:
| import('@automaker/types').ClaudeCompatibleProvider
| undefined;
let credentials: import('@automaker/types').Credentials | undefined;
if (resolvedModel && settingsService) {
const providerResult = await getProviderByModelId(
resolvedModel,
settingsService,
'[AutoModeFacade]'
);
if (providerResult.provider) {
claudeCompatibleProvider = providerResult.provider;
credentials = providerResult.credentials;
}
}
await agentExecutor.execute(
{
workDir,
@@ -187,6 +264,8 @@ export class AutoModeServiceFacade {
branchName: opts?.branchName as string | null | undefined,
provider,
effectiveBareModel,
credentials,
claudeCompatibleProvider,
},
{
waitForApproval: (fId, projPath) => planApprovalService.waitForApproval(fId, projPath),
@@ -199,7 +278,7 @@ export class AutoModeServiceFacade {
.replace(/\{\{taskName\}\}/g, task.description)
.replace(/\{\{taskIndex\}\}/g, String(taskIndex + 1))
.replace(/\{\{totalTasks\}\}/g, String(allTasks.length))
.replace(/\{\{taskDescription\}\}/g, task.description || task.description);
.replace(/\{\{taskDescription\}\}/g, task.description || `Task ${task.id}`);
if (feedback) {
taskPrompt = taskPrompt.replace(/\{\{userFeedback\}\}/g, feedback);
}
@@ -220,22 +299,26 @@ export class AutoModeServiceFacade {
settingsService,
// Callbacks
(pPath, featureId, useWorktrees, isAutoMode) =>
facadeInstance!.executeFeature(featureId, useWorktrees, isAutoMode),
(pPath, branchName) =>
featureLoader
.getAll(pPath)
.then((features) =>
features.filter(
(f) =>
(f.status === 'backlog' || f.status === 'ready') &&
(branchName === null
? !f.branchName || f.branchName === 'main'
: f.branchName === branchName)
)
),
getFacade().executeFeature(featureId, useWorktrees, isAutoMode),
async (pPath, branchName) => {
const features = await featureLoader.getAll(pPath);
// For main worktree (branchName === null), resolve the actual primary branch name
// so features with branchName matching the primary branch are included
let primaryBranch: string | null = null;
if (branchName === null) {
primaryBranch = await worktreeResolver.getCurrentBranch(pPath);
}
return features.filter(
(f) =>
(f.status === 'backlog' || f.status === 'ready') &&
(branchName === null
? !f.branchName || (primaryBranch && f.branchName === primaryBranch)
: f.branchName === branchName)
);
},
(pPath, branchName, maxConcurrency) =>
facadeInstance!.saveExecutionStateForProject(branchName, maxConcurrency),
(pPath, branchName) => facadeInstance!.clearExecutionState(branchName),
getFacade().saveExecutionStateForProject(branchName, maxConcurrency),
(pPath, branchName) => getFacade().clearExecutionState(branchName),
(pPath) => featureStateManager.resetStuckFeatures(pPath),
(feature) =>
feature.status === 'completed' ||
@@ -273,6 +356,23 @@ export class AutoModeServiceFacade {
const provider = ProviderFactory.getProviderForModel(resolvedModel);
const effectiveBareModel = stripProviderPrefix(resolvedModel);
// Resolve custom provider (GLM, MiniMax, etc.) for baseUrl and credentials
let claudeCompatibleProvider:
| import('@automaker/types').ClaudeCompatibleProvider
| undefined;
let credentials: import('@automaker/types').Credentials | undefined;
if (resolvedModel && settingsService) {
const providerResult = await getProviderByModelId(
resolvedModel,
settingsService,
'[AutoModeFacade]'
);
if (providerResult.provider) {
claudeCompatibleProvider = providerResult.provider;
credentials = providerResult.credentials;
}
}
await agentExecutor.execute(
{
workDir,
@@ -290,6 +390,8 @@ export class AutoModeServiceFacade {
branchName: opts?.branchName,
provider,
effectiveBareModel,
credentials,
claudeCompatibleProvider,
},
{
waitForApproval: (fId, projPath) => planApprovalService.waitForApproval(fId, projPath),
@@ -324,16 +426,16 @@ export class AutoModeServiceFacade {
async () => {
/* recordLearnings - stub */
},
(pPath, featureId) => facadeInstance!.contextExists(featureId),
(pPath, featureId) => getFacade().contextExists(featureId),
(pPath, featureId, useWorktrees, _calledInternally) =>
facadeInstance!.resumeFeature(featureId, useWorktrees, _calledInternally),
getFacade().resumeFeature(featureId, useWorktrees, _calledInternally),
(errorInfo) =>
autoLoopCoordinator.trackFailureAndCheckPauseForProject(projectPath, null, errorInfo),
(errorInfo) => autoLoopCoordinator.signalShouldPauseForProject(projectPath, null, errorInfo),
() => {
/* recordSuccess - no-op */
},
(_pPath) => facadeInstance!.saveExecutionState(),
(_pPath) => getFacade().saveExecutionState(),
loadContextFiles
);
@@ -344,13 +446,7 @@ export class AutoModeServiceFacade {
settingsService,
// Callbacks
(pPath, featureId, useWorktrees, isAutoMode, providedWorktreePath, opts) =>
facadeInstance!.executeFeature(
featureId,
useWorktrees,
isAutoMode,
providedWorktreePath,
opts
),
getFacade().executeFeature(featureId, useWorktrees, isAutoMode, providedWorktreePath, opts),
(pPath, featureId) => featureStateManager.loadFeature(pPath, featureId),
(pPath, featureId, status) =>
pipelineOrchestrator.detectPipelineStatus(pPath, featureId, status),
@@ -391,11 +487,16 @@ export class AutoModeServiceFacade {
* @param maxConcurrency - Maximum concurrent features
*/
async startAutoLoop(branchName: string | null = null, maxConcurrency?: number): Promise<number> {
return this.autoLoopCoordinator.startAutoLoopForProject(
this.projectPath,
branchName,
maxConcurrency
);
try {
return await this.autoLoopCoordinator.startAutoLoopForProject(
this.projectPath,
branchName,
maxConcurrency
);
} catch (error) {
this.handleFacadeError(error, 'startAutoLoop');
throw error;
}
}
/**
@@ -403,7 +504,12 @@ export class AutoModeServiceFacade {
* @param branchName - The branch name, or null for main worktree
*/
async stopAutoLoop(branchName: string | null = null): Promise<number> {
return this.autoLoopCoordinator.stopAutoLoopForProject(this.projectPath, branchName);
try {
return await this.autoLoopCoordinator.stopAutoLoopForProject(this.projectPath, branchName);
} catch (error) {
this.handleFacadeError(error, 'stopAutoLoop');
throw error;
}
}
/**
@@ -444,14 +550,19 @@ export class AutoModeServiceFacade {
_calledInternally?: boolean;
}
): Promise<void> {
return this.executionService.executeFeature(
this.projectPath,
featureId,
useWorktrees,
isAutoMode,
providedWorktreePath,
options
);
try {
return await this.executionService.executeFeature(
this.projectPath,
featureId,
useWorktrees,
isAutoMode,
providedWorktreePath,
options
);
} catch (error) {
this.handleFacadeError(error, 'executeFeature', featureId);
throw error;
}
}
/**
@@ -459,9 +570,14 @@ export class AutoModeServiceFacade {
* @param featureId - ID of the feature to stop
*/
async stopFeature(featureId: string): Promise<boolean> {
// Cancel any pending plan approval for this feature
this.cancelPlanApproval(featureId);
return this.executionService.stopFeature(featureId);
try {
// Cancel any pending plan approval for this feature
this.cancelPlanApproval(featureId);
return await this.executionService.stopFeature(featureId);
} catch (error) {
this.handleFacadeError(error, 'stopFeature', featureId);
throw error;
}
}
/**
@@ -496,97 +612,67 @@ export class AutoModeServiceFacade {
imagePaths?: string[],
useWorktrees = true
): Promise<void> {
// This method contains substantial logic - delegates most work to AgentExecutor
validateWorkingDirectory(this.projectPath);
const runningEntry = this.concurrencyManager.acquire({
featureId,
projectPath: this.projectPath,
isAutoMode: false,
});
const abortController = runningEntry.abortController;
try {
// Load feature to build the prompt context
const feature = await this.featureStateManager.loadFeature(this.projectPath, featureId);
if (!feature) throw new Error(`Feature ${featureId} not found`);
const feature = await this.featureStateManager.loadFeature(this.projectPath, featureId);
let workDir = path.resolve(this.projectPath);
let worktreePath: string | null = null;
const branchName = feature?.branchName || `feature/${featureId}`;
if (useWorktrees && branchName) {
worktreePath = await this.worktreeResolver.findWorktreeForBranch(
this.projectPath,
branchName
);
if (worktreePath) {
workDir = worktreePath;
// Read previous agent output as context
const featureDir = getFeatureDir(this.projectPath, featureId);
let previousContext = '';
try {
previousContext = (await secureFs.readFile(
path.join(featureDir, 'agent-output.md'),
'utf-8'
)) as string;
} catch {
// No previous context available - that's OK
}
}
// Load previous context
const featureDir = getFeatureDir(this.projectPath, featureId);
const contextPath = path.join(featureDir, 'agent-output.md');
let previousContext = '';
try {
previousContext = (await secureFs.readFile(contextPath, 'utf-8')) as string;
} catch {
// No previous context
}
// Build the feature prompt section
const featurePrompt = `## Feature Implementation Task\n\n**Feature ID:** ${feature.id}\n**Title:** ${feature.title || 'Untitled Feature'}\n**Description:** ${feature.description}\n`;
const prompts = await getPromptCustomization(this.settingsService, '[Facade]');
// Get the follow-up prompt template and build the continuation prompt
const prompts = await getPromptCustomization(this.settingsService, '[Facade]');
let continuationPrompt = prompts.autoMode.followUpPromptTemplate;
continuationPrompt = continuationPrompt
.replace(/\{\{featurePrompt\}\}/g, featurePrompt)
.replace(/\{\{previousContext\}\}/g, previousContext)
.replace(/\{\{followUpInstructions\}\}/g, prompt);
// Build follow-up prompt inline (no template in TaskExecutionPrompts)
let fullPrompt = `## Follow-up on Feature Implementation
// Store image paths on the feature so executeFeature can pick them up
if (imagePaths && imagePaths.length > 0) {
feature.imagePaths = imagePaths.map((p) => ({
path: p,
filename: p.split('/').pop() || p,
mimeType: 'image/*',
}));
await this.featureStateManager.updateFeatureStatus(
this.projectPath,
featureId,
feature.status || 'in_progress'
);
}
${feature ? `**Feature ID:** ${feature.id}\n**Title:** ${feature.title || 'Untitled'}\n**Description:** ${feature.description}` : `**Feature ID:** ${featureId}`}
`;
if (previousContext) {
fullPrompt += `
## Previous Agent Work
The following is the output from the previous implementation attempt:
${previousContext}
`;
}
fullPrompt += `
## Follow-up Instructions
${prompt}
## Task
Address the follow-up instructions above. Review the previous work and make the requested changes or fixes.`;
try {
this.eventBus.emitAutoModeEvent('auto_mode_feature_start', {
featureId,
projectPath: this.projectPath,
branchName: feature?.branchName ?? null,
feature: {
id: featureId,
title: feature?.title || 'Follow-up',
description: feature?.description || 'Following up on feature',
},
// Delegate to executeFeature with the built continuation prompt
await this.executeFeature(featureId, useWorktrees, false, undefined, {
continuationPrompt,
});
// NOTE: Facade does not have runAgent - this method requires AutoModeService
// For now, throw to indicate routes should use AutoModeService.followUpFeature
throw new Error(
'followUpFeature not fully implemented in facade - use AutoModeService.followUpFeature instead'
);
} catch (error) {
const errorInfo = classifyError(error);
if (!errorInfo.isAbort) {
this.eventBus.emitAutoModeEvent('auto_mode_error', {
featureId,
featureName: feature?.title,
branchName: feature?.branchName ?? null,
featureName: undefined,
branchName: null,
error: errorInfo.message,
errorType: errorInfo.type,
projectPath: this.projectPath,
});
}
throw error;
} finally {
this.concurrencyManager.release(featureId);
}
}
@@ -596,15 +682,23 @@ Address the follow-up instructions above. Review the previous work and make the
*/
async verifyFeature(featureId: string): Promise<boolean> {
const feature = await this.featureStateManager.loadFeature(this.projectPath, featureId);
const sanitizedFeatureId = featureId.replace(/[^a-zA-Z0-9_-]/g, '-');
const worktreePath = path.join(this.projectPath, '.worktrees', sanitizedFeatureId);
let workDir = this.projectPath;
try {
await secureFs.access(worktreePath);
workDir = worktreePath;
} catch {
// No worktree
// Use worktreeResolver to find worktree path (consistent with commitFeature)
const branchName = feature?.branchName;
if (branchName) {
const resolved = await this.worktreeResolver.findWorktreeForBranch(
this.projectPath,
branchName
);
if (resolved) {
try {
await secureFs.access(resolved);
workDir = resolved;
} catch {
// Fall back to project path
}
}
}
const verificationChecks = [
@@ -658,18 +752,22 @@ Address the follow-up instructions above. Review the previous work and make the
// Use project path
}
} else {
const sanitizedFeatureId = featureId.replace(/[^a-zA-Z0-9_-]/g, '-');
const legacyWorktreePath = path.join(this.projectPath, '.worktrees', sanitizedFeatureId);
try {
await secureFs.access(legacyWorktreePath);
workDir = legacyWorktreePath;
} catch {
// Use project path
// Use worktreeResolver instead of manual .worktrees lookup
const feature = await this.featureStateManager.loadFeature(this.projectPath, featureId);
const branchName = feature?.branchName;
if (branchName) {
const resolved = await this.worktreeResolver.findWorktreeForBranch(
this.projectPath,
branchName
);
if (resolved) {
workDir = resolved;
}
}
}
try {
const { stdout: status } = await execAsync('git status --porcelain', { cwd: workDir });
const status = await execGitCommand(['status', '--porcelain'], workDir);
if (!status.trim()) {
return null;
}
@@ -679,9 +777,9 @@ Address the follow-up instructions above. Review the previous work and make the
feature?.description?.split('\n')[0]?.substring(0, 60) || `Feature ${featureId}`;
const commitMessage = `feat: ${title}\n\nImplemented by Automaker auto-mode`;
await execAsync('git add -A', { cwd: workDir });
await execAsync(`git commit -m "${commitMessage.replace(/"/g, '\\"')}"`, { cwd: workDir });
const { stdout: hash } = await execAsync('git rev-parse HEAD', { cwd: workDir });
await execGitCommand(['add', '-A'], workDir);
await execGitCommand(['commit', '-m', commitMessage], workDir);
const hash = await execGitCommand(['rev-parse', 'HEAD'], workDir);
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
featureId,
@@ -719,7 +817,7 @@ Address the follow-up instructions above. Review the previous work and make the
* Get status for this project/worktree
* @param branchName - The branch name, or null for main worktree
*/
getStatusForProject(branchName: string | null = null): ProjectAutoModeStatus {
async getStatusForProject(branchName: string | null = null): Promise<ProjectAutoModeStatus> {
const isAutoLoopRunning = this.autoLoopCoordinator.isAutoLoopRunningForProject(
this.projectPath,
branchName
@@ -728,10 +826,12 @@ Address the follow-up instructions above. Review the previous work and make the
this.projectPath,
branchName
);
const runningFeatures = this.concurrencyManager
.getAllRunning()
.filter((f) => f.projectPath === this.projectPath && f.branchName === branchName)
.map((f) => f.featureId);
// Use branchName-normalized filter so features with branchName "main"
// are correctly matched when querying for the main worktree (null)
const runningFeatures = await this.concurrencyManager.getRunningFeaturesForWorktree(
this.projectPath,
branchName
);
return {
isAutoLoopRunning,
@@ -800,7 +900,9 @@ Address the follow-up instructions above. Review the previous work and make the
async checkWorktreeCapacity(featureId: string): Promise<WorktreeCapacityInfo> {
const feature = await this.featureStateManager.loadFeature(this.projectPath, featureId);
const rawBranchName = feature?.branchName ?? null;
const branchName = rawBranchName === 'main' ? null : rawBranchName;
// Normalize primary branch to null (works for main, master, or any default branch)
const primaryBranch = await this.worktreeResolver.getCurrentBranch(this.projectPath);
const branchName = rawBranchName === primaryBranch ? null : rawBranchName;
const maxAgents = await this.autoLoopCoordinator.resolveMaxConcurrency(
this.projectPath,
@@ -940,10 +1042,10 @@ Address the follow-up instructions above. Review the previous work and make the
return orphanedFeatures;
}
// Get existing branches
const { stdout } = await execAsync(
'git for-each-ref --format="%(refname:short)" refs/heads/',
{ cwd: this.projectPath }
// Get existing branches (using safe array-based command)
const stdout = await execGitCommand(
['for-each-ref', '--format=%(refname:short)', 'refs/heads/'],
this.projectPath
);
const existingBranches = new Set(
stdout

View File

@@ -66,18 +66,22 @@ export class GlobalAutoModeService {
);
},
// getBacklogFeaturesFn
(pPath, branchName) =>
featureLoader
.getAll(pPath)
.then((features) =>
features.filter(
(f) =>
(f.status === 'backlog' || f.status === 'ready') &&
(branchName === null
? !f.branchName || f.branchName === 'main'
: f.branchName === branchName)
)
),
async (pPath, branchName) => {
const features = await featureLoader.getAll(pPath);
// For main worktree (branchName === null), resolve the actual primary branch name
// so features with branchName matching the primary branch are included
let primaryBranch: string | null = null;
if (branchName === null) {
primaryBranch = await this.worktreeResolver.getCurrentBranch(pPath);
}
return features.filter(
(f) =>
(f.status === 'backlog' || f.status === 'ready') &&
(branchName === null
? !f.branchName || (primaryBranch && f.branchName === primaryBranch)
: f.branchName === branchName)
);
},
// saveExecutionStateFn - placeholder
async () => {},
// clearExecutionStateFn - placeholder
@@ -201,4 +205,21 @@ export class GlobalAutoModeService {
);
}
}
/**
* Reconcile all feature states for a project on server startup.
*
* Resets features stuck in transient states (in_progress, interrupted, pipeline_*)
* back to a resting state and emits events so the UI reflects corrected states.
*
* This should be called during server initialization to handle:
* - Clean shutdown: features already marked as interrupted
* - Forced kill / crash: features left in in_progress or pipeline_* states
*
* @param projectPath - The project path to reconcile
* @returns The number of features that were reconciled
*/
async reconcileFeatureStates(projectPath: string): Promise<number> {
return this.featureStateManager.reconcileAllFeatureStates(projectPath);
}
}

View File

@@ -58,6 +58,7 @@ export type {
WorktreeCapacityInfo,
RunningAgentInfo,
OrphanedFeatureInfo,
FacadeError,
GlobalAutoModeOperations,
} from './types.js';

View File

@@ -15,6 +15,7 @@ import type { ConcurrencyManager } from '../concurrency-manager.js';
import type { AutoLoopCoordinator } from '../auto-loop-coordinator.js';
import type { WorktreeResolver } from '../worktree-resolver.js';
import type { TypedEventBus } from '../typed-event-bus.js';
import type { ClaudeUsageService } from '../claude-usage-service.js';
// Re-export types from extracted services for route consumption
export type { AutoModeConfig, ProjectAutoLoopState } from '../auto-loop-coordinator.js';
@@ -55,6 +56,8 @@ export interface FacadeOptions {
featureLoader?: FeatureLoader;
/** Shared services for state sharing across facades (optional) */
sharedServices?: SharedServices;
/** ClaudeUsageService for checking usage limits before picking up features (optional) */
claudeUsageService?: ClaudeUsageService | null;
}
/**
@@ -110,6 +113,23 @@ export interface OrphanedFeatureInfo {
missingBranch: string;
}
/**
* Structured error object returned/emitted by facade methods.
* Provides consistent error information for callers and UI consumers.
*/
export interface FacadeError {
/** The facade method where the error originated */
method: string;
/** Classified error type from the error handler */
errorType: import('@automaker/types').ErrorType;
/** Human-readable error message */
message: string;
/** Feature ID if the error is associated with a specific feature */
featureId?: string;
/** Project path where the error occurred */
projectPath: string;
}
/**
* Interface describing global auto-mode operations (not project-specific).
* Used by routes that need global state access.

View File

@@ -294,7 +294,16 @@ export class ClaudeUsageService {
this.killPtyProcess(ptyProcess);
}
// Don't fail if we have data - return it instead
if (output.includes('Current session')) {
// Check cleaned output since raw output has ANSI codes between words
// eslint-disable-next-line no-control-regex
const cleanedForCheck = output
.replace(/\x1B\[(\d+)C/g, (_m: string, n: string) => ' '.repeat(parseInt(n, 10)))
.replace(/\x1B\[[0-9;?]*[A-Za-z@]/g, '');
if (
cleanedForCheck.includes('Current session') ||
cleanedForCheck.includes('% used') ||
cleanedForCheck.includes('% left')
) {
resolve(output);
} else if (hasSeenTrustPrompt) {
// Trust prompt was shown but we couldn't auto-approve it
@@ -320,8 +329,13 @@ export class ClaudeUsageService {
output += data;
// Strip ANSI codes for easier matching
// Convert cursor forward (ESC[nC) to spaces first to preserve word boundaries,
// then strip remaining ANSI sequences. Without this, the Claude CLI TUI output
// like "Current week (all models)" becomes "Currentweek(allmodels)".
// eslint-disable-next-line no-control-regex
const cleanOutput = output.replace(/\x1B\[[0-9;]*[A-Za-z]/g, '');
const cleanOutput = output
.replace(/\x1B\[(\d+)C/g, (_match: string, n: string) => ' '.repeat(parseInt(n, 10)))
.replace(/\x1B\[[0-9;?]*[A-Za-z@]/g, '');
// Check for specific authentication/permission errors
// Must be very specific to avoid false positives from garbled terminal encoding
@@ -356,7 +370,8 @@ export class ClaudeUsageService {
const hasUsageIndicators =
cleanOutput.includes('Current session') ||
(cleanOutput.includes('Usage') && cleanOutput.includes('% left')) ||
// Additional patterns for winpty - look for percentage patterns
// Look for percentage patterns - allow optional whitespace between % and left/used
// since cursor movement codes may or may not create spaces after stripping
/\d+%\s*(left|used|remaining)/i.test(cleanOutput) ||
cleanOutput.includes('Resets in') ||
cleanOutput.includes('Current week');
@@ -382,12 +397,15 @@ export class ClaudeUsageService {
// Handle Trust Dialog - multiple variants:
// - "Do you want to work in this folder?"
// - "Ready to code here?" / "I'll need permission to work with your files"
// - "Quick safety check" / "Yes, I trust this folder"
// Since we are running in cwd (project dir), it is safe to approve.
if (
!hasApprovedTrust &&
(cleanOutput.includes('Do you want to work in this folder?') ||
cleanOutput.includes('Ready to code here') ||
cleanOutput.includes('permission to work with your files'))
cleanOutput.includes('permission to work with your files') ||
cleanOutput.includes('trust this folder') ||
cleanOutput.includes('safety check'))
) {
hasApprovedTrust = true;
hasSeenTrustPrompt = true;
@@ -471,10 +489,17 @@ export class ClaudeUsageService {
* Handles CSI, OSC, and other common ANSI sequences
*/
private stripAnsiCodes(text: string): string {
// First strip ANSI sequences (colors, etc) and handle CR
// First, convert cursor movement sequences to whitespace to preserve word boundaries.
// The Claude CLI TUI uses ESC[nC (cursor forward) instead of actual spaces between words.
// Without this, "Current week (all models)" becomes "Currentweek(allmodels)" after stripping.
// eslint-disable-next-line no-control-regex
let clean = text
// CSI sequences: ESC [ ... (letter or @)
// Cursor forward (CSI n C): replace with n spaces to preserve word separation
.replace(/\x1B\[(\d+)C/g, (_match, n) => ' '.repeat(parseInt(n, 10)))
// Cursor movement (up/down/back/position): replace with newline or nothing
.replace(/\x1B\[\d*[ABD]/g, '') // cursor up (A), down (B), back (D)
.replace(/\x1B\[\d+;\d+[Hf]/g, '\n') // cursor position (H/f)
// Now strip remaining CSI sequences (colors, modes, etc.)
.replace(/\x1B\[[0-9;?]*[A-Za-z@]/g, '')
// OSC sequences: ESC ] ... terminated by BEL, ST, or another ESC
.replace(/\x1B\][^\x07\x1B]*(?:\x07|\x1B\\)?/g, '')
@@ -637,7 +662,7 @@ export class ClaudeUsageService {
resetTime = this.parseResetTime(resetText, type);
// Strip timezone like "(Asia/Dubai)" from the display text
resetText = resetText.replace(/\s*\([A-Za-z_\/]+\)\s*$/, '').trim();
resetText = resetText.replace(/\s*\([A-Za-z_/]+\)\s*$/, '').trim();
}
return { percentage: percentage ?? 0, resetTime, resetText };

View File

@@ -209,6 +209,41 @@ export class ConcurrencyManager {
return Array.from(this.runningFeatures.values());
}
/**
* Get running feature IDs for a specific worktree, with proper primary branch normalization.
*
* When branchName is null (main worktree), matches features with branchName === null
* OR branchName matching the primary branch (e.g., "main", "master").
*
* @param projectPath - The project path
* @param branchName - The branch name, or null for main worktree
* @returns Array of feature IDs running in the specified worktree
*/
async getRunningFeaturesForWorktree(
projectPath: string,
branchName: string | null
): Promise<string[]> {
const primaryBranch = await this.getCurrentBranch(projectPath);
const featureIds: string[] = [];
for (const [, feature] of this.runningFeatures) {
if (feature.projectPath !== projectPath) continue;
const featureBranch = feature.branchName ?? null;
if (branchName === null) {
// Main worktree: match features with null branchName OR primary branch name
const isPrimaryBranch =
featureBranch === null || (primaryBranch && featureBranch === primaryBranch);
if (isPrimaryBranch) featureIds.push(feature.featureId);
} else {
// Feature worktree: exact match
if (featureBranch === branchName) featureIds.push(feature.featureId);
}
}
return featureIds;
}
/**
* Update properties of a running feature
*

View File

@@ -37,6 +37,8 @@ export interface DevServerInfo {
flushTimeout: NodeJS.Timeout | null;
// Flag to indicate server is stopping (prevents output after stop)
stopping: boolean;
// Flag to indicate if URL has been detected from output
urlDetected: boolean;
}
// Port allocation starts at 3001 to avoid conflicts with common dev ports
@@ -103,6 +105,54 @@ class DevServerService {
}
}
/**
* Detect actual server URL from output
* Parses stdout/stderr for common URL patterns from dev servers
*/
private detectUrlFromOutput(server: DevServerInfo, content: string): void {
// Skip if URL already detected
if (server.urlDetected) {
return;
}
// Common URL patterns from various dev servers:
// - Vite: "Local: http://localhost:5173/"
// - Next.js: "ready - started server on 0.0.0.0:3000, url: http://localhost:3000"
// - CRA/Webpack: "On Your Network: http://192.168.1.1:3000"
// - Generic: Any http:// or https:// URL
const urlPatterns = [
/(?:Local|Network):\s+(https?:\/\/[^\s]+)/i, // Vite format
/(?:ready|started server).*?(?:url:\s*)?(https?:\/\/[^\s,]+)/i, // Next.js format
/(https?:\/\/(?:localhost|127\.0\.0\.1|\[::\]):\d+)/i, // Generic localhost URL
/(https?:\/\/[^\s<>"{}|\\^`[\]]+)/i, // Any HTTP(S) URL
];
for (const pattern of urlPatterns) {
const match = content.match(pattern);
if (match && match[1]) {
const detectedUrl = match[1].trim();
// Validate it looks like a reasonable URL
if (detectedUrl.startsWith('http://') || detectedUrl.startsWith('https://')) {
server.url = detectedUrl;
server.urlDetected = true;
logger.info(
`Detected actual server URL: ${detectedUrl} (allocated port was ${server.port})`
);
// Emit URL update event
if (this.emitter) {
this.emitter.emit('dev-server:url-detected', {
worktreePath: server.worktreePath,
url: detectedUrl,
timestamp: new Date().toISOString(),
});
}
break;
}
}
}
}
/**
* Handle incoming stdout/stderr data from dev server process
* Buffers data for scrollback replay and schedules throttled emission
@@ -115,6 +165,9 @@ class DevServerService {
const content = data.toString();
// Try to detect actual server URL from output
this.detectUrlFromOutput(server, content);
// Append to scrollback buffer for replay on reconnect
this.appendToScrollback(server, content);
@@ -446,13 +499,14 @@ class DevServerService {
const serverInfo: DevServerInfo = {
worktreePath,
port,
url: `http://${hostname}:${port}`,
url: `http://${hostname}:${port}`, // Initial URL, may be updated by detectUrlFromOutput
process: devProcess,
startedAt: new Date(),
scrollbackBuffer: '',
outputBuffer: '',
flushTimeout: null,
stopping: false,
urlDetected: false, // Will be set to true when actual URL is detected from output
};
// Capture stdout with buffer management and event emission

View File

@@ -190,9 +190,9 @@ ${feature.spec}
}
}
let worktreePath: string | null = null;
let worktreePath: string | null = providedWorktreePath ?? null;
const branchName = feature.branchName;
if (useWorktrees && branchName) {
if (!worktreePath && useWorktrees && branchName) {
worktreePath = await this.worktreeResolver.findWorktreeForBranch(projectPath, branchName);
if (worktreePath) logger.info(`Using worktree for branch "${branchName}": ${worktreePath}`);
}
@@ -270,6 +270,84 @@ ${feature.spec}
}
);
// Check for incomplete tasks after agent execution.
// The agent may have finished early (hit max turns, decided it was done, etc.)
// while tasks are still pending. If so, re-run the agent to complete remaining tasks.
const MAX_TASK_RETRY_ATTEMPTS = 3;
let taskRetryAttempts = 0;
while (!abortController.signal.aborted && taskRetryAttempts < MAX_TASK_RETRY_ATTEMPTS) {
const currentFeature = await this.loadFeatureFn(projectPath, featureId);
if (!currentFeature?.planSpec?.tasks) break;
const pendingTasks = currentFeature.planSpec.tasks.filter(
(t) => t.status === 'pending' || t.status === 'in_progress'
);
if (pendingTasks.length === 0) break;
taskRetryAttempts++;
const totalTasks = currentFeature.planSpec.tasks.length;
const completedTasks = currentFeature.planSpec.tasks.filter(
(t) => t.status === 'completed'
).length;
logger.info(
`[executeFeature] Feature ${featureId} has ${pendingTasks.length} incomplete tasks (${completedTasks}/${totalTasks} completed). Re-running agent (attempt ${taskRetryAttempts}/${MAX_TASK_RETRY_ATTEMPTS})`
);
this.eventBus.emitAutoModeEvent('auto_mode_progress', {
featureId,
branchName: feature.branchName ?? null,
content: `Agent finished with ${pendingTasks.length} tasks remaining. Re-running to complete tasks (attempt ${taskRetryAttempts}/${MAX_TASK_RETRY_ATTEMPTS})...`,
projectPath,
});
// Build a continuation prompt that tells the agent to finish remaining tasks
const remainingTasksList = pendingTasks
.map((t) => `- ${t.id}: ${t.description} (${t.status})`)
.join('\n');
const continuationPrompt = `## Continue Implementation - Incomplete Tasks
The previous agent session ended before all tasks were completed. Please continue implementing the remaining tasks.
**Completed:** ${completedTasks}/${totalTasks} tasks
**Remaining tasks:**
${remainingTasksList}
Please continue from where you left off and complete all remaining tasks. Use the same [TASK_START:ID] and [TASK_COMPLETE:ID] markers for each task.`;
await this.runAgentFn(
workDir,
featureId,
continuationPrompt,
abortController,
projectPath,
undefined,
model,
{
projectPath,
planningMode: 'skip',
requirePlanApproval: false,
systemPrompt: combinedSystemPrompt || undefined,
autoLoadClaudeMd,
thinkingLevel: feature.thinkingLevel,
branchName: feature.branchName ?? null,
}
);
}
// Log if tasks are still incomplete after retry attempts
if (taskRetryAttempts >= MAX_TASK_RETRY_ATTEMPTS) {
const finalFeature = await this.loadFeatureFn(projectPath, featureId);
const stillPending = finalFeature?.planSpec?.tasks?.filter(
(t) => t.status === 'pending' || t.status === 'in_progress'
);
if (stillPending && stillPending.length > 0) {
logger.warn(
`[executeFeature] Feature ${featureId} still has ${stillPending.length} incomplete tasks after ${MAX_TASK_RETRY_ATTEMPTS} retry attempts. Moving to final status.`
);
}
}
const pipelineConfig = await pipelineService.getPipelineConfig(projectPath);
const excludedStepIds = new Set(feature.excludedPipelineSteps || []);
const sortedSteps = [...(pipelineConfig?.steps || [])]
@@ -289,12 +367,24 @@ ${feature.spec}
testAttempts: 0,
maxTestAttempts: 5,
});
// Check if pipeline set a terminal status (e.g., merge_conflict) — don't overwrite it
const refreshed = await this.loadFeatureFn(projectPath, featureId);
if (refreshed?.status === 'merge_conflict') {
return;
}
}
const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified';
await this.updateFeatureStatusFn(projectPath, featureId, finalStatus);
this.recordSuccessFn();
// Check final task completion state for accurate reporting
const completedFeature = await this.loadFeatureFn(projectPath, featureId);
const totalTasks = completedFeature?.planSpec?.tasks?.length ?? 0;
const completedTasks =
completedFeature?.planSpec?.tasks?.filter((t) => t.status === 'completed').length ?? 0;
const hasIncompleteTasks = totalTasks > 0 && completedTasks < totalTasks;
try {
const outputPath = path.join(getFeatureDir(projectPath, featureId), 'agent-output.md');
let agentOutput = '';
@@ -321,12 +411,18 @@ ${feature.spec}
/* learnings recording failed */
}
const elapsedSeconds = Math.round((Date.now() - tempRunningFeature.startTime) / 1000);
let completionMessage = `Feature completed in ${elapsedSeconds}s`;
if (finalStatus === 'verified') completionMessage += ' - auto-verified';
if (hasIncompleteTasks)
completionMessage += ` (${completedTasks}/${totalTasks} tasks completed)`;
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
featureId,
featureName: feature.title,
branchName: feature.branchName ?? null,
passes: true,
message: `Feature completed in ${Math.round((Date.now() - tempRunningFeature.startTime) / 1000)}s${finalStatus === 'verified' ? ' - auto-verified' : ''}`,
message: completionMessage,
projectPath,
model: tempRunningFeature.model,
provider: tempRunningFeature.provider,
@@ -334,6 +430,7 @@ ${feature.spec}
} catch (error) {
const errorInfo = classifyError(error);
if (errorInfo.isAbort) {
await this.updateFeatureStatusFn(projectPath, featureId, 'interrupted');
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
featureId,
featureName: feature?.title,

View File

@@ -25,6 +25,7 @@ import {
import { getFeatureDir, getFeaturesDir } from '@automaker/platform';
import * as secureFs from '../lib/secure-fs.js';
import type { EventEmitter } from '../lib/events.js';
import type { AutoModeEventType } from './typed-event-bus.js';
import { getNotificationService } from './notification-service.js';
import { FeatureLoader } from './feature-loader.js';
@@ -107,6 +108,70 @@ export class FeatureStateManager {
// Badge will show for 2 minutes after this timestamp
if (status === 'waiting_approval') {
feature.justFinishedAt = new Date().toISOString();
// Finalize task statuses when feature is done:
// - Mark any in_progress tasks as completed (agent finished but didn't explicitly complete them)
// - Do NOT mark pending tasks as completed (they were never started)
// - Clear currentTaskId since no task is actively running
// This prevents cards in "waiting for review" from appearing to still have running tasks
if (feature.planSpec?.tasks) {
let tasksFinalized = 0;
let tasksPending = 0;
for (const task of feature.planSpec.tasks) {
if (task.status === 'in_progress') {
task.status = 'completed';
tasksFinalized++;
} else if (task.status === 'pending') {
tasksPending++;
}
}
if (tasksFinalized > 0) {
logger.info(
`[updateFeatureStatus] Finalized ${tasksFinalized} in_progress tasks for feature ${featureId} moving to waiting_approval`
);
}
if (tasksPending > 0) {
logger.warn(
`[updateFeatureStatus] Feature ${featureId} moving to waiting_approval with ${tasksPending} pending (never started) tasks out of ${feature.planSpec.tasks.length} total`
);
}
// Update tasksCompleted count to reflect actual completed tasks
feature.planSpec.tasksCompleted = feature.planSpec.tasks.filter(
(t) => t.status === 'completed'
).length;
feature.planSpec.currentTaskId = undefined;
}
} else if (status === 'verified') {
// Also finalize in_progress tasks when moving directly to verified (skipTests=false)
// Do NOT mark pending tasks as completed - they were never started
if (feature.planSpec?.tasks) {
let tasksFinalized = 0;
let tasksPending = 0;
for (const task of feature.planSpec.tasks) {
if (task.status === 'in_progress') {
task.status = 'completed';
tasksFinalized++;
} else if (task.status === 'pending') {
tasksPending++;
}
}
if (tasksFinalized > 0) {
logger.info(
`[updateFeatureStatus] Finalized ${tasksFinalized} in_progress tasks for feature ${featureId} moving to verified`
);
}
if (tasksPending > 0) {
logger.warn(
`[updateFeatureStatus] Feature ${featureId} moving to verified with ${tasksPending} pending (never started) tasks out of ${feature.planSpec.tasks.length} total`
);
}
feature.planSpec.tasksCompleted = feature.planSpec.tasks.filter(
(t) => t.status === 'completed'
).length;
feature.planSpec.currentTaskId = undefined;
}
// Clear the timestamp when moving to other statuses
feature.justFinishedAt = undefined;
} else {
// Clear the timestamp when moving to other statuses
feature.justFinishedAt = undefined;
@@ -115,24 +180,36 @@ export class FeatureStateManager {
// PERSIST BEFORE EMIT (Pitfall 2)
await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT });
// Emit status change event so UI can react without polling
this.emitAutoModeEvent('feature_status_changed', {
featureId,
projectPath,
status,
});
// Create notifications for important status changes
const notificationService = getNotificationService();
if (status === 'waiting_approval') {
await notificationService.createNotification({
type: 'feature_waiting_approval',
title: 'Feature Ready for Review',
message: `"${feature.name || featureId}" is ready for your review and approval.`,
featureId,
projectPath,
});
} else if (status === 'verified') {
await notificationService.createNotification({
type: 'feature_verified',
title: 'Feature Verified',
message: `"${feature.name || featureId}" has been verified and is complete.`,
featureId,
projectPath,
});
// Wrapped in try-catch so failures don't block syncFeatureToAppSpec below
try {
const notificationService = getNotificationService();
if (status === 'waiting_approval') {
await notificationService.createNotification({
type: 'feature_waiting_approval',
title: 'Feature Ready for Review',
message: `"${feature.name || featureId}" is ready for your review and approval.`,
featureId,
projectPath,
});
} else if (status === 'verified') {
await notificationService.createNotification({
type: 'feature_verified',
title: 'Feature Verified',
message: `"${feature.name || featureId}" has been verified and is complete.`,
featureId,
projectPath,
});
}
} catch (notificationError) {
logger.warn(`Failed to create notification for feature ${featureId}:`, notificationError);
}
// Sync completed/verified features to app_spec.txt
@@ -192,18 +269,39 @@ export class FeatureStateManager {
}
/**
* Reset features that were stuck in transient states due to server crash.
* Called when auto mode is enabled to clean up from previous session.
* Shared helper that scans features in a project directory and resets any stuck
* in transient states (in_progress, interrupted, pipeline_*) back to resting states.
*
* Resets:
* - in_progress features back to ready (if has plan) or backlog (if no plan)
* Also resets:
* - generating planSpec status back to pending
* - in_progress tasks back to pending
*
* @param projectPath - The project path to reset features for
* @param projectPath - The project path to scan
* @param callerLabel - Label for log messages (e.g., 'resetStuckFeatures', 'reconcileAllFeatureStates')
* @returns Object with reconciledFeatures (id + status info), reconciledCount, and scanned count
*/
async resetStuckFeatures(projectPath: string): Promise<void> {
private async scanAndResetFeatures(
projectPath: string,
callerLabel: string
): Promise<{
reconciledFeatures: Array<{
id: string;
previousStatus: string | undefined;
newStatus: string | undefined;
}>;
reconciledFeatureIds: string[];
reconciledCount: number;
scanned: number;
}> {
const featuresDir = getFeaturesDir(projectPath);
let scanned = 0;
let reconciledCount = 0;
const reconciledFeatureIds: string[] = [];
const reconciledFeatures: Array<{
id: string;
previousStatus: string | undefined;
newStatus: string | undefined;
}> = [];
try {
const entries = await secureFs.readdir(featuresDir, { withFileTypes: true });
@@ -211,6 +309,7 @@ export class FeatureStateManager {
for (const entry of entries) {
if (!entry.isDirectory()) continue;
scanned++;
const featurePath = path.join(featuresDir, entry.name, 'feature.json');
const result = await readJsonWithRecovery<Feature | null>(featurePath, null, {
maxBackups: DEFAULT_BACKUP_COUNT,
@@ -221,14 +320,21 @@ export class FeatureStateManager {
if (!feature) continue;
let needsUpdate = false;
const originalStatus = feature.status;
// Reset in_progress features back to ready/backlog
if (feature.status === 'in_progress') {
// Reset features in active execution states back to a resting state
// After a server restart, no processes are actually running
const isActiveState =
originalStatus === 'in_progress' ||
originalStatus === 'interrupted' ||
(originalStatus != null && originalStatus.startsWith('pipeline_'));
if (isActiveState) {
const hasApprovedPlan = feature.planSpec?.status === 'approved';
feature.status = hasApprovedPlan ? 'ready' : 'backlog';
needsUpdate = true;
logger.info(
`[resetStuckFeatures] Reset feature ${feature.id} from in_progress to ${feature.status}`
`[${callerLabel}] Reset feature ${feature.id} from ${originalStatus} to ${feature.status}`
);
}
@@ -237,7 +343,7 @@ export class FeatureStateManager {
feature.planSpec.status = 'pending';
needsUpdate = true;
logger.info(
`[resetStuckFeatures] Reset feature ${feature.id} planSpec status from generating to pending`
`[${callerLabel}] Reset feature ${feature.id} planSpec status from generating to pending`
);
}
@@ -248,13 +354,13 @@ export class FeatureStateManager {
task.status = 'pending';
needsUpdate = true;
logger.info(
`[resetStuckFeatures] Reset task ${task.id} for feature ${feature.id} from in_progress to pending`
`[${callerLabel}] Reset task ${task.id} for feature ${feature.id} from in_progress to pending`
);
// Clear currentTaskId if it points to this reverted task
if (feature.planSpec?.currentTaskId === task.id) {
feature.planSpec.currentTaskId = undefined;
logger.info(
`[resetStuckFeatures] Cleared planSpec.currentTaskId for feature ${feature.id} (was pointing to reverted task ${task.id})`
`[${callerLabel}] Cleared planSpec.currentTaskId for feature ${feature.id} (was pointing to reverted task ${task.id})`
);
}
}
@@ -264,14 +370,94 @@ export class FeatureStateManager {
if (needsUpdate) {
feature.updatedAt = new Date().toISOString();
await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT });
reconciledCount++;
reconciledFeatureIds.push(feature.id);
reconciledFeatures.push({
id: feature.id,
previousStatus: originalStatus,
newStatus: feature.status,
});
}
}
} catch (error) {
// If features directory doesn't exist, that's fine
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
logger.error(`[resetStuckFeatures] Error resetting features for ${projectPath}:`, error);
logger.error(`[${callerLabel}] Error resetting features for ${projectPath}:`, error);
}
}
return { reconciledFeatures, reconciledFeatureIds, reconciledCount, scanned };
}
/**
* Reset features that were stuck in transient states due to server crash.
* Called when auto mode is enabled to clean up from previous session.
*
* Resets:
* - in_progress features back to ready (if has plan) or backlog (if no plan)
* - interrupted features back to ready (if has plan) or backlog (if no plan)
* - pipeline_* features back to ready (if has plan) or backlog (if no plan)
* - generating planSpec status back to pending
* - in_progress tasks back to pending
*
* @param projectPath - The project path to reset features for
*/
async resetStuckFeatures(projectPath: string): Promise<void> {
const { reconciledCount, scanned } = await this.scanAndResetFeatures(
projectPath,
'resetStuckFeatures'
);
logger.info(
`[resetStuckFeatures] Scanned ${scanned} features, reset ${reconciledCount} features for ${projectPath}`
);
}
/**
* Reconcile all feature states on server startup.
*
* This method resets all features stuck in transient states (in_progress,
* interrupted, pipeline_*) and emits events so connected UI clients
* immediately reflect the corrected states.
*
* Should be called once during server initialization, before the UI is served,
* to ensure feature state consistency after any type of restart (clean, forced, crash).
*
* @param projectPath - The project path to reconcile features for
* @returns The number of features that were reconciled
*/
async reconcileAllFeatureStates(projectPath: string): Promise<number> {
logger.info(`[reconcileAllFeatureStates] Starting reconciliation for ${projectPath}`);
const { reconciledFeatures, reconciledFeatureIds, reconciledCount, scanned } =
await this.scanAndResetFeatures(projectPath, 'reconcileAllFeatureStates');
// Emit per-feature status change events so UI invalidates its cache
for (const { id, previousStatus, newStatus } of reconciledFeatures) {
this.emitAutoModeEvent('feature_status_changed', {
featureId: id,
projectPath,
status: newStatus,
previousStatus,
reason: 'server_restart_reconciliation',
});
}
// Emit a bulk reconciliation event for the UI
if (reconciledCount > 0) {
this.emitAutoModeEvent('features_reconciled', {
projectPath,
reconciledCount,
reconciledFeatureIds,
message: `Reconciled ${reconciledCount} feature(s) after server restart`,
});
}
logger.info(
`[reconcileAllFeatureStates] Scanned ${scanned} features, reconciled ${reconciledCount} for ${projectPath}`
);
return reconciledCount;
}
/**
@@ -319,7 +505,7 @@ export class FeatureStateManager {
Object.assign(feature.planSpec, updates);
// If content is being updated and it's different from old content, increment version
if (updates.content && updates.content !== oldContent) {
if (updates.content !== undefined && updates.content !== oldContent) {
feature.planSpec.version = (feature.planSpec.version || 0) + 1;
}
@@ -327,6 +513,13 @@ export class FeatureStateManager {
// PERSIST BEFORE EMIT
await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT });
// Emit event for UI update
this.emitAutoModeEvent('plan_spec_updated', {
featureId,
projectPath,
planSpec: feature.planSpec,
});
} catch (error) {
logger.error(`Failed to update planSpec for ${featureId}:`, error);
}
@@ -424,6 +617,11 @@ export class FeatureStateManager {
status,
tasks: feature.planSpec.tasks,
});
} else {
const availableIds = feature.planSpec.tasks.map((t) => t.id).join(', ');
logger.warn(
`[updateTaskStatus] Task ${taskId} not found in feature ${featureId} (${projectPath}). Available task IDs: [${availableIds}]`
);
}
} catch (error) {
logger.error(`Failed to update task ${taskId} status for ${featureId}:`, error);
@@ -436,7 +634,7 @@ export class FeatureStateManager {
* @param eventType - The event type (e.g., 'auto_mode_summary')
* @param data - The event payload
*/
private emitAutoModeEvent(eventType: string, data: Record<string, unknown>): void {
private emitAutoModeEvent(eventType: AutoModeEventType, data: Record<string, unknown>): void {
// Wrap the event in auto-mode:event format expected by the client
this.events.emit('auto-mode:event', {
type: eventType,

View File

@@ -230,10 +230,9 @@ export class IdeationService {
);
if (providerResult.provider) {
claudeCompatibleProvider = providerResult.provider;
// Use resolved model from provider if available (maps to Claude model)
if (providerResult.resolvedModel) {
modelId = providerResult.resolvedModel;
}
// CRITICAL: For custom providers, use the provider's model ID (e.g. "GLM-4.7")
// for the API call, NOT the resolved Claude model - otherwise we get "model not found"
modelId = options.model;
credentials = providerResult.credentials ?? credentials;
}
}
@@ -889,7 +888,7 @@ ${contextSection}${existingWorkSection}`;
for (const line of lines) {
// Check for numbered items or markdown headers
const titleMatch = line.match(/^(?:\d+[\.\)]\s*\*{0,2}|#{1,3}\s+)(.+)/);
const titleMatch = line.match(/^(?:\d+[.)]\s*\*{0,2}|#{1,3}\s+)(.+)/);
if (titleMatch) {
// Save previous suggestion

View File

@@ -4,12 +4,8 @@
* Extracted from worktree merge route to allow internal service calls.
*/
import { exec } from 'child_process';
import { promisify } from 'util';
import { createLogger } from '@automaker/utils';
import { spawnProcess } from '@automaker/platform';
const execAsync = promisify(exec);
const logger = createLogger('MergeService');
export interface MergeOptions {
@@ -80,9 +76,23 @@ export async function performMerge(
const mergeTo = targetBranch || 'main';
// Validate source branch exists
// Validate branch names early to reject invalid input before any git operations
if (!isValidBranchName(branchName)) {
return {
success: false,
error: `Invalid source branch name: "${branchName}"`,
};
}
if (!isValidBranchName(mergeTo)) {
return {
success: false,
error: `Invalid target branch name: "${mergeTo}"`,
};
}
// Validate source branch exists (using safe array-based command)
try {
await execAsync(`git rev-parse --verify ${branchName}`, { cwd: projectPath });
await execGitCommand(['rev-parse', '--verify', branchName], projectPath);
} catch {
return {
success: false,
@@ -90,9 +100,9 @@ export async function performMerge(
};
}
// Validate target branch exists
// Validate target branch exists (using safe array-based command)
try {
await execAsync(`git rev-parse --verify ${mergeTo}`, { cwd: projectPath });
await execGitCommand(['rev-parse', '--verify', mergeTo], projectPath);
} catch {
return {
success: false,
@@ -100,13 +110,14 @@ export async function performMerge(
};
}
// Merge the feature branch into the target branch
const mergeCmd = options?.squash
? `git merge --squash ${branchName}`
: `git merge ${branchName} -m "${options?.message || `Merge ${branchName} into ${mergeTo}`}"`;
// Merge the feature branch into the target branch (using safe array-based commands)
const mergeMessage = options?.message || `Merge ${branchName} into ${mergeTo}`;
const mergeArgs = options?.squash
? ['merge', '--squash', branchName]
: ['merge', branchName, '-m', mergeMessage];
try {
await execAsync(mergeCmd, { cwd: projectPath });
await execGitCommand(mergeArgs, projectPath);
} catch (mergeError: unknown) {
// Check if this is a merge conflict
const err = mergeError as { stdout?: string; stderr?: string; message?: string };
@@ -125,11 +136,10 @@ export async function performMerge(
throw mergeError;
}
// If squash merge, need to commit
// If squash merge, need to commit (using safe array-based command)
if (options?.squash) {
await execAsync(`git commit -m "${options?.message || `Merge ${branchName} (squash)`}"`, {
cwd: projectPath,
});
const squashMessage = options?.message || `Merge ${branchName} (squash)`;
await execGitCommand(['commit', '-m', squashMessage], projectPath);
}
// Optionally delete the worktree and branch after merging

View File

@@ -361,8 +361,14 @@ export class PipelineOrchestrator {
await this.executePipeline(context);
// Re-fetch feature to check if executePipeline set a terminal status (e.g., merge_conflict)
const reloadedFeature = await this.featureStateManager.loadFeature(projectPath, featureId);
const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified';
await this.updateFeatureStatusFn(projectPath, featureId, finalStatus);
// Only update status if not already in a terminal state
if (reloadedFeature && reloadedFeature.status !== 'merge_conflict') {
await this.updateFeatureStatusFn(projectPath, featureId, finalStatus);
}
logger.info(`Pipeline resume completed for feature ${featureId}`);
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
featureId,
@@ -417,7 +423,10 @@ export class PipelineOrchestrator {
message: testResult.error || 'Failed to start tests',
};
const completionResult = await this.waitForTestCompletion(testResult.result.sessionId);
const completionResult = await this.waitForTestCompletion(
testResult.result.sessionId,
abortController.signal
);
if (completionResult.status === 'passed') return { success: true, testsPassed: true };
const sessionOutput = this.testRunnerService.getSessionOutput(testResult.result.sessionId);
@@ -453,13 +462,23 @@ export class PipelineOrchestrator {
/** Wait for test completion */
private async waitForTestCompletion(
sessionId: string
sessionId: string,
signal: AbortSignal
): Promise<{ status: TestRunStatus; exitCode: number | null; duration: number }> {
return new Promise((resolve) => {
const checkInterval = setInterval(() => {
// Check for abort
if (signal.aborted) {
clearInterval(checkInterval);
clearTimeout(timeoutId);
resolve({ status: 'failed', exitCode: null, duration: 0 });
return;
}
const session = this.testRunnerService.getSession(sessionId);
if (session && session.status !== 'running' && session.status !== 'pending') {
clearInterval(checkInterval);
clearTimeout(timeoutId);
resolve({
status: session.status,
exitCode: session.exitCode,
@@ -469,7 +488,13 @@ export class PipelineOrchestrator {
});
}
}, 1000);
setTimeout(() => {
const timeoutId = setTimeout(() => {
// Check for abort before timeout resolution
if (signal.aborted) {
clearInterval(checkInterval);
resolve({ status: 'failed', exitCode: null, duration: 0 });
return;
}
clearInterval(checkInterval);
resolve({ status: 'failed', exitCode: null, duration: 600000 });
}, 600000);
@@ -483,12 +508,15 @@ export class PipelineOrchestrator {
logger.info(`Attempting auto-merge for feature ${featureId} (branch: ${branchName})`);
try {
// Get the primary branch dynamically instead of hardcoding 'main'
const targetBranch = await this.worktreeResolver.getCurrentBranch(projectPath);
// Call merge service directly instead of HTTP fetch
const result = await performMerge(
projectPath,
branchName,
worktreePath || projectPath,
'main',
targetBranch || 'main',
{
deleteWorktreeAndBranch: false,
}
@@ -523,43 +551,62 @@ export class PipelineOrchestrator {
}
}
/** Build a concise test failure summary for the agent */
buildTestFailureSummary(scrollback: string): string {
/** Shared helper to parse test output lines and extract failure information */
private parseTestLines(scrollback: string): {
failedTests: string[];
passCount: number;
failCount: number;
} {
const lines = scrollback.split('\n');
const failedTests: string[] = [];
let passCount = 0,
failCount = 0;
let passCount = 0;
let failCount = 0;
let inFailureContext = false;
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.includes('FAIL') || trimmed.includes('FAILED')) {
const match = trimmed.match(/(?:FAIL|FAILED)\s+(.+)/);
if (match) failedTests.push(match[1].trim());
failCount++;
} else if (trimmed.includes('PASS') || trimmed.includes('PASSED')) passCount++;
if (trimmed.match(/^>\s+.*\.(test|spec)\./)) failedTests.push(trimmed.replace(/^>\s+/, ''));
if (
trimmed.includes('AssertionError') ||
trimmed.includes('toBe') ||
trimmed.includes('toEqual')
)
inFailureContext = true;
} else if (trimmed.includes('PASS') || trimmed.includes('PASSED')) {
passCount++;
inFailureContext = false;
}
if (trimmed.match(/^>\s+.*\.(test|spec)\./)) {
failedTests.push(trimmed.replace(/^>\s+/, ''));
}
// Only capture assertion details when they appear in failure context
// or match explicit assertion error / expect patterns
if (trimmed.includes('AssertionError') || trimmed.includes('AssertionError')) {
failedTests.push(trimmed);
} else if (
inFailureContext &&
/expect\(.+\)\.(toBe|toEqual|toMatch|toThrow|toContain)\s*\(/.test(trimmed)
) {
failedTests.push(trimmed);
} else if (
inFailureContext &&
(trimmed.startsWith('Expected') || trimmed.startsWith('Received'))
) {
failedTests.push(trimmed);
}
}
return { failedTests, passCount, failCount };
}
/** Build a concise test failure summary for the agent */
buildTestFailureSummary(scrollback: string): string {
const { failedTests, passCount, failCount } = this.parseTestLines(scrollback);
const unique = [...new Set(failedTests)].slice(0, 10);
return `Test Results: ${passCount} passed, ${failCount} failed.\n\nFailed tests:\n${unique.map((t) => `- ${t}`).join('\n')}\n\nOutput (last 2000 chars):\n${scrollback.slice(-2000)}`;
}
/** Extract failed test names from scrollback */
private extractFailedTestNames(scrollback: string): string[] {
const failedTests: string[] = [];
for (const line of scrollback.split('\n')) {
const trimmed = line.trim();
if (trimmed.includes('FAIL') || trimmed.includes('FAILED')) {
const match = trimmed.match(/(?:FAIL|FAILED)\s+(.+)/);
if (match) failedTests.push(match[1].trim());
}
}
const { failedTests } = this.parseTestLines(scrollback);
return [...new Set(failedTests)].slice(0, 20);
}
}

View File

@@ -83,25 +83,17 @@ export class PlanApprovalService {
);
return new Promise((resolve, reject) => {
// Set up timeout to prevent indefinite waiting and memory leaks
// timeoutId stored in closure, NOT in PendingApproval object
const timeoutId = setTimeout(() => {
const pending = this.pendingApprovals.get(key);
if (pending) {
logger.warn(
`Plan approval for feature ${featureId} timed out after ${timeoutMinutes} minutes`
);
this.pendingApprovals.delete(key);
reject(
new Error(
`Plan approval timed out after ${timeoutMinutes} minutes - feature execution cancelled`
)
);
}
}, timeoutMs);
// Prevent duplicate registrations for the same key — reject and clean up existing entry
const existing = this.pendingApprovals.get(key);
if (existing) {
existing.reject(new Error('Superseded by a new waitForApproval call'));
this.pendingApprovals.delete(key);
}
// Wrap resolve/reject to clear timeout when approval is resolved
// This ensures timeout is ALWAYS cleared on any resolution path
// Define wrappers BEFORE setTimeout so they can be used in timeout callback
let timeoutId: NodeJS.Timeout;
const wrappedResolve = (result: PlanApprovalResult) => {
clearTimeout(timeoutId);
resolve(result);
@@ -112,6 +104,23 @@ export class PlanApprovalService {
reject(error);
};
// Set up timeout to prevent indefinite waiting and memory leaks
// Now timeoutId assignment happens after wrappers are defined
timeoutId = setTimeout(() => {
const pending = this.pendingApprovals.get(key);
if (pending) {
logger.warn(
`Plan approval for feature ${featureId} timed out after ${timeoutMinutes} minutes`
);
this.pendingApprovals.delete(key);
wrappedReject(
new Error(
`Plan approval timed out after ${timeoutMinutes} minutes - feature execution cancelled`
)
);
}
}, timeoutMs);
this.pendingApprovals.set(key, {
resolve: wrappedResolve,
reject: wrappedReject,
@@ -226,11 +235,11 @@ export class PlanApprovalService {
status: approved ? 'approved' : 'rejected',
approvedAt: approved ? new Date().toISOString() : undefined,
reviewedByUser: true,
content: editedPlan, // Update content if user provided an edited version
...(editedPlan !== undefined && { content: editedPlan }), // Only update content if user provided an edited version
});
// If rejected with feedback, emit event so client knows the rejection reason
if (!approved && feedback) {
// If rejected, emit event so client knows the rejection reason (even without feedback)
if (!approved) {
this.eventBus.emitAutoModeEvent('plan_rejected', {
featureId,
projectPath,

View File

@@ -13,6 +13,14 @@ import * as path from 'path';
// to enforce ALLOWED_ROOT_DIRECTORY security boundary
import * as secureFs from '../lib/secure-fs.js';
import { createLogger } from '@automaker/utils';
import type { SettingsService } from './settings-service.js';
import { getTerminalThemeColors, getAllTerminalThemes } from '../lib/terminal-themes-data.js';
import {
getRcFilePath,
getTerminalDir,
ensureRcFilesUpToDate,
type TerminalConfig,
} from '@automaker/platform';
const logger = createLogger('Terminal');
// System paths module handles shell binary checks and WSL detection
@@ -24,6 +32,27 @@ import {
getShellPaths,
} from '@automaker/platform';
const BASH_LOGIN_ARG = '--login';
const BASH_RCFILE_ARG = '--rcfile';
const SHELL_NAME_BASH = 'bash';
const SHELL_NAME_ZSH = 'zsh';
const SHELL_NAME_SH = 'sh';
const DEFAULT_SHOW_USER_HOST = true;
const DEFAULT_SHOW_PATH = true;
const DEFAULT_SHOW_TIME = false;
const DEFAULT_SHOW_EXIT_STATUS = false;
const DEFAULT_PATH_DEPTH = 0;
const DEFAULT_PATH_STYLE: TerminalConfig['pathStyle'] = 'full';
const DEFAULT_CUSTOM_PROMPT = true;
const DEFAULT_PROMPT_FORMAT: TerminalConfig['promptFormat'] = 'standard';
const DEFAULT_SHOW_GIT_BRANCH = true;
const DEFAULT_SHOW_GIT_STATUS = true;
const DEFAULT_CUSTOM_ALIASES = '';
const DEFAULT_CUSTOM_ENV_VARS: Record<string, string> = {};
const PROMPT_THEME_CUSTOM = 'custom';
const PROMPT_THEME_PREFIX = 'omp-';
const OMP_THEME_ENV_VAR = 'AUTOMAKER_OMP_THEME';
// Maximum scrollback buffer size (characters)
const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per terminal
@@ -42,6 +71,114 @@ let maxSessions = parseInt(process.env.TERMINAL_MAX_SESSIONS || '1000', 10);
const OUTPUT_THROTTLE_MS = 4; // ~250fps max update rate for responsive input
const OUTPUT_BATCH_SIZE = 4096; // Smaller batches for lower latency
function applyBashRcFileArgs(args: string[], rcFilePath: string): string[] {
const sanitizedArgs: string[] = [];
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === BASH_LOGIN_ARG) {
continue;
}
if (arg === BASH_RCFILE_ARG) {
index += 1;
continue;
}
sanitizedArgs.push(arg);
}
sanitizedArgs.push(BASH_RCFILE_ARG, rcFilePath);
return sanitizedArgs;
}
function normalizePathStyle(
pathStyle: TerminalConfig['pathStyle'] | undefined
): TerminalConfig['pathStyle'] {
if (pathStyle === 'short' || pathStyle === 'basename') {
return pathStyle;
}
return DEFAULT_PATH_STYLE;
}
function normalizePathDepth(pathDepth: number | undefined): number {
const depth =
typeof pathDepth === 'number' && Number.isFinite(pathDepth) ? pathDepth : DEFAULT_PATH_DEPTH;
return Math.max(DEFAULT_PATH_DEPTH, Math.floor(depth));
}
function getShellBasename(shellPath: string): string {
const lastSep = Math.max(shellPath.lastIndexOf('/'), shellPath.lastIndexOf('\\'));
return lastSep >= 0 ? shellPath.slice(lastSep + 1) : shellPath;
}
function getShellArgsForPath(shellPath: string): string[] {
const shellName = getShellBasename(shellPath).toLowerCase().replace('.exe', '');
if (shellName === 'powershell' || shellName === 'pwsh' || shellName === 'cmd') {
return [];
}
if (shellName === SHELL_NAME_SH) {
return [];
}
return [BASH_LOGIN_ARG];
}
function resolveOmpThemeName(promptTheme: string | undefined): string | null {
if (!promptTheme || promptTheme === PROMPT_THEME_CUSTOM) {
return null;
}
if (promptTheme.startsWith(PROMPT_THEME_PREFIX)) {
return promptTheme.slice(PROMPT_THEME_PREFIX.length);
}
return null;
}
function buildEffectiveTerminalConfig(
globalTerminalConfig: TerminalConfig | undefined,
projectTerminalConfig: Partial<TerminalConfig> | undefined
): TerminalConfig {
const mergedEnvVars = {
...(globalTerminalConfig?.customEnvVars ?? DEFAULT_CUSTOM_ENV_VARS),
...(projectTerminalConfig?.customEnvVars ?? DEFAULT_CUSTOM_ENV_VARS),
};
return {
enabled: projectTerminalConfig?.enabled ?? globalTerminalConfig?.enabled ?? false,
customPrompt: globalTerminalConfig?.customPrompt ?? DEFAULT_CUSTOM_PROMPT,
promptFormat: globalTerminalConfig?.promptFormat ?? DEFAULT_PROMPT_FORMAT,
showGitBranch:
projectTerminalConfig?.showGitBranch ??
globalTerminalConfig?.showGitBranch ??
DEFAULT_SHOW_GIT_BRANCH,
showGitStatus:
projectTerminalConfig?.showGitStatus ??
globalTerminalConfig?.showGitStatus ??
DEFAULT_SHOW_GIT_STATUS,
showUserHost:
projectTerminalConfig?.showUserHost ??
globalTerminalConfig?.showUserHost ??
DEFAULT_SHOW_USER_HOST,
showPath:
projectTerminalConfig?.showPath ?? globalTerminalConfig?.showPath ?? DEFAULT_SHOW_PATH,
pathStyle: normalizePathStyle(
projectTerminalConfig?.pathStyle ?? globalTerminalConfig?.pathStyle
),
pathDepth: normalizePathDepth(
projectTerminalConfig?.pathDepth ?? globalTerminalConfig?.pathDepth
),
showTime:
projectTerminalConfig?.showTime ?? globalTerminalConfig?.showTime ?? DEFAULT_SHOW_TIME,
showExitStatus:
projectTerminalConfig?.showExitStatus ??
globalTerminalConfig?.showExitStatus ??
DEFAULT_SHOW_EXIT_STATUS,
customAliases:
projectTerminalConfig?.customAliases ??
globalTerminalConfig?.customAliases ??
DEFAULT_CUSTOM_ALIASES,
customEnvVars: mergedEnvVars,
rcFileVersion: globalTerminalConfig?.rcFileVersion,
};
}
export interface TerminalSession {
id: string;
pty: pty.IPty;
@@ -77,6 +214,12 @@ export class TerminalService extends EventEmitter {
!!(process.versions && (process.versions as Record<string, string>).electron) ||
!!process.env.ELECTRON_RUN_AS_NODE;
private useConptyFallback = false; // Track if we need to use winpty fallback on Windows
private settingsService: SettingsService | null = null;
constructor(settingsService?: SettingsService) {
super();
this.settingsService = settingsService || null;
}
/**
* Kill a PTY process with platform-specific handling.
@@ -102,37 +245,19 @@ export class TerminalService extends EventEmitter {
const platform = os.platform();
const shellPaths = getShellPaths();
// Helper to get basename handling both path separators
const getBasename = (shellPath: string): string => {
const lastSep = Math.max(shellPath.lastIndexOf('/'), shellPath.lastIndexOf('\\'));
return lastSep >= 0 ? shellPath.slice(lastSep + 1) : shellPath;
};
// Helper to get shell args based on shell name
const getShellArgs = (shell: string): string[] => {
const shellName = getBasename(shell).toLowerCase().replace('.exe', '');
// PowerShell and cmd don't need --login
if (shellName === 'powershell' || shellName === 'pwsh' || shellName === 'cmd') {
return [];
}
// sh doesn't support --login in all implementations
if (shellName === 'sh') {
return [];
}
// bash, zsh, and other POSIX shells support --login
return ['--login'];
};
// Check if running in WSL - prefer user's shell or bash with --login
if (platform === 'linux' && this.isWSL()) {
const userShell = process.env.SHELL;
if (userShell) {
// Try to find userShell in allowed paths
for (const allowedShell of shellPaths) {
if (allowedShell === userShell || getBasename(allowedShell) === getBasename(userShell)) {
if (
allowedShell === userShell ||
getShellBasename(allowedShell) === getShellBasename(userShell)
) {
try {
if (systemPathExists(allowedShell)) {
return { shell: allowedShell, args: getShellArgs(allowedShell) };
return { shell: allowedShell, args: getShellArgsForPath(allowedShell) };
}
} catch {
// Path not allowed, continue searching
@@ -144,7 +269,7 @@ export class TerminalService extends EventEmitter {
for (const shell of shellPaths) {
try {
if (systemPathExists(shell)) {
return { shell, args: getShellArgs(shell) };
return { shell, args: getShellArgsForPath(shell) };
}
} catch {
// Path not allowed, continue
@@ -158,10 +283,13 @@ export class TerminalService extends EventEmitter {
if (userShell && platform !== 'win32') {
// Try to find userShell in allowed paths
for (const allowedShell of shellPaths) {
if (allowedShell === userShell || getBasename(allowedShell) === getBasename(userShell)) {
if (
allowedShell === userShell ||
getShellBasename(allowedShell) === getShellBasename(userShell)
) {
try {
if (systemPathExists(allowedShell)) {
return { shell: allowedShell, args: getShellArgs(allowedShell) };
return { shell: allowedShell, args: getShellArgsForPath(allowedShell) };
}
} catch {
// Path not allowed, continue searching
@@ -174,7 +302,7 @@ export class TerminalService extends EventEmitter {
for (const shell of shellPaths) {
try {
if (systemPathExists(shell)) {
return { shell, args: getShellArgs(shell) };
return { shell, args: getShellArgsForPath(shell) };
}
} catch {
// Path not allowed or doesn't exist, continue to next
@@ -313,8 +441,9 @@ export class TerminalService extends EventEmitter {
const id = `term-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
const { shell: detectedShell, args: shellArgs } = this.detectShell();
const { shell: detectedShell, args: detectedShellArgs } = this.detectShell();
const shell = options.shell || detectedShell;
let shellArgs = options.shell ? getShellArgsForPath(shell) : [...detectedShellArgs];
// Validate and resolve working directory
// Uses secureFs internally to enforce ALLOWED_ROOT_DIRECTORY
@@ -332,6 +461,89 @@ export class TerminalService extends EventEmitter {
}
}
// Terminal config injection (custom prompts, themes)
const terminalConfigEnv: Record<string, string> = {};
if (this.settingsService) {
try {
logger.info(
`[createSession] Checking terminal config for session ${id}, cwd: ${options.cwd || cwd}`
);
const globalSettings = await this.settingsService.getGlobalSettings();
const projectSettings = options.cwd
? await this.settingsService.getProjectSettings(options.cwd)
: null;
const globalTerminalConfig = globalSettings?.terminalConfig;
const projectTerminalConfig = projectSettings?.terminalConfig;
const effectiveConfig = buildEffectiveTerminalConfig(
globalTerminalConfig,
projectTerminalConfig
);
logger.info(
`[createSession] Terminal config: global.enabled=${globalTerminalConfig?.enabled}, project.enabled=${projectTerminalConfig?.enabled}`
);
logger.info(
`[createSession] Terminal config effective enabled: ${effectiveConfig.enabled}`
);
if (effectiveConfig.enabled && globalTerminalConfig) {
const currentTheme = globalSettings?.theme || 'dark';
const themeColors = getTerminalThemeColors(currentTheme);
const allThemes = getAllTerminalThemes();
const promptTheme =
projectTerminalConfig?.promptTheme ?? globalTerminalConfig.promptTheme;
const ompThemeName = resolveOmpThemeName(promptTheme);
// Ensure RC files are up to date
await ensureRcFilesUpToDate(
options.cwd || cwd,
currentTheme,
effectiveConfig,
themeColors,
allThemes
);
// Set shell-specific env vars
const shellName = getShellBasename(shell).toLowerCase();
if (ompThemeName && effectiveConfig.customPrompt) {
terminalConfigEnv[OMP_THEME_ENV_VAR] = ompThemeName;
}
if (shellName.includes(SHELL_NAME_BASH)) {
const bashRcFilePath = getRcFilePath(options.cwd || cwd, SHELL_NAME_BASH);
terminalConfigEnv.BASH_ENV = bashRcFilePath;
terminalConfigEnv.AUTOMAKER_CUSTOM_PROMPT = effectiveConfig.customPrompt
? 'true'
: 'false';
terminalConfigEnv.AUTOMAKER_THEME = currentTheme;
shellArgs = applyBashRcFileArgs(shellArgs, bashRcFilePath);
} else if (shellName.includes(SHELL_NAME_ZSH)) {
terminalConfigEnv.ZDOTDIR = getTerminalDir(options.cwd || cwd);
terminalConfigEnv.AUTOMAKER_CUSTOM_PROMPT = effectiveConfig.customPrompt
? 'true'
: 'false';
terminalConfigEnv.AUTOMAKER_THEME = currentTheme;
} else if (shellName === SHELL_NAME_SH) {
terminalConfigEnv.ENV = getRcFilePath(options.cwd || cwd, SHELL_NAME_SH);
terminalConfigEnv.AUTOMAKER_CUSTOM_PROMPT = effectiveConfig.customPrompt
? 'true'
: 'false';
terminalConfigEnv.AUTOMAKER_THEME = currentTheme;
}
// Add custom env vars from config
Object.assign(terminalConfigEnv, effectiveConfig.customEnvVars);
logger.info(
`[createSession] Terminal config enabled for session ${id}, shell: ${shellName}`
);
}
} catch (error) {
logger.warn(`[createSession] Failed to apply terminal config: ${error}`);
}
}
const env: Record<string, string> = {
...cleanEnv,
TERM: 'xterm-256color',
@@ -341,6 +553,7 @@ export class TerminalService extends EventEmitter {
LANG: process.env.LANG || 'en_US.UTF-8',
LC_ALL: process.env.LC_ALL || process.env.LANG || 'en_US.UTF-8',
...options.env,
...terminalConfigEnv, // Apply terminal config env vars last (highest priority)
};
logger.info(`Creating session ${id} with shell: ${shell} in ${cwd}`);
@@ -652,6 +865,44 @@ export class TerminalService extends EventEmitter {
return () => this.exitCallbacks.delete(callback);
}
/**
* Handle theme change - regenerate RC files with new theme colors
*/
async onThemeChange(projectPath: string, newTheme: string): Promise<void> {
if (!this.settingsService) {
logger.warn('[onThemeChange] SettingsService not available');
return;
}
try {
const globalSettings = await this.settingsService.getGlobalSettings();
const terminalConfig = globalSettings?.terminalConfig;
const projectSettings = await this.settingsService.getProjectSettings(projectPath);
const projectTerminalConfig = projectSettings?.terminalConfig;
const effectiveConfig = buildEffectiveTerminalConfig(terminalConfig, projectTerminalConfig);
if (effectiveConfig.enabled && terminalConfig) {
const themeColors = getTerminalThemeColors(
newTheme as import('@automaker/types').ThemeMode
);
const allThemes = getAllTerminalThemes();
// Regenerate RC files with new theme
await ensureRcFilesUpToDate(
projectPath,
newTheme as import('@automaker/types').ThemeMode,
effectiveConfig,
themeColors,
allThemes
);
logger.info(`[onThemeChange] Regenerated RC files for theme: ${newTheme}`);
}
} catch (error) {
logger.error(`[onThemeChange] Failed to regenerate RC files: ${error}`);
}
}
/**
* Clean up all sessions
*/
@@ -676,9 +927,9 @@ export class TerminalService extends EventEmitter {
// Singleton instance
let terminalService: TerminalService | null = null;
export function getTerminalService(): TerminalService {
export function getTerminalService(settingsService?: SettingsService): TerminalService {
if (!terminalService) {
terminalService = new TerminalService();
terminalService = new TerminalService(settingsService);
}
return terminalService;
}

View File

@@ -40,9 +40,13 @@ export type AutoModeEventType =
| 'plan_rejected'
| 'plan_revision_requested'
| 'plan_revision_warning'
| 'plan_spec_updated'
| 'pipeline_step_started'
| 'pipeline_step_complete'
| string; // Allow other strings for extensibility
| 'pipeline_test_failed'
| 'pipeline_merge_conflict'
| 'feature_status_changed'
| 'features_reconciled';
/**
* TypedEventBus wraps an EventEmitter to provide type-safe event emission

View File

@@ -35,7 +35,7 @@ describe('model-resolver.ts', () => {
it("should resolve 'opus' alias to full model string", () => {
const result = resolveModelString('opus');
expect(result).toBe('claude-opus-4-5-20251101');
expect(result).toBe('claude-opus-4-6');
expect(consoleSpy.log).toHaveBeenCalledWith(
expect.stringContaining('Migrated legacy ID: "opus" -> "claude-opus"')
);
@@ -117,7 +117,7 @@ describe('model-resolver.ts', () => {
describe('getEffectiveModel', () => {
it('should prioritize explicit model over session and default', () => {
const result = getEffectiveModel('opus', 'haiku', 'gpt-5.2');
expect(result).toBe('claude-opus-4-5-20251101');
expect(result).toBe('claude-opus-4-6');
});
it('should use session model when explicit is not provided', () => {

View File

@@ -491,5 +491,29 @@ describe('sdk-options.ts', () => {
expect(options.maxThinkingTokens).toBeUndefined();
});
});
describe('adaptive thinking for Opus 4.6', () => {
it('should not set maxThinkingTokens for adaptive thinking (model decides)', async () => {
const { createAutoModeOptions } = await import('@/lib/sdk-options.js');
const options = createAutoModeOptions({
cwd: '/test/path',
thinkingLevel: 'adaptive',
});
expect(options.maxThinkingTokens).toBeUndefined();
});
it('should not include maxThinkingTokens when thinkingLevel is "none"', async () => {
const { createAutoModeOptions } = await import('@/lib/sdk-options.js');
const options = createAutoModeOptions({
cwd: '/test/path',
thinkingLevel: 'none',
});
expect(options.maxThinkingTokens).toBeUndefined();
});
});
});
});

View File

@@ -39,7 +39,7 @@ describe('claude-provider.ts', () => {
const generator = provider.executeQuery({
prompt: 'Hello',
model: 'claude-opus-4-5-20251101',
model: 'claude-opus-4-6',
cwd: '/test',
});
@@ -59,7 +59,7 @@ describe('claude-provider.ts', () => {
const generator = provider.executeQuery({
prompt: 'Test prompt',
model: 'claude-opus-4-5-20251101',
model: 'claude-opus-4-6',
cwd: '/test/dir',
systemPrompt: 'You are helpful',
maxTurns: 10,
@@ -71,7 +71,7 @@ describe('claude-provider.ts', () => {
expect(sdk.query).toHaveBeenCalledWith({
prompt: 'Test prompt',
options: expect.objectContaining({
model: 'claude-opus-4-5-20251101',
model: 'claude-opus-4-6',
systemPrompt: 'You are helpful',
maxTurns: 10,
cwd: '/test/dir',
@@ -91,7 +91,7 @@ describe('claude-provider.ts', () => {
const generator = provider.executeQuery({
prompt: 'Test',
model: 'claude-opus-4-5-20251101',
model: 'claude-opus-4-6',
cwd: '/test',
});
@@ -116,7 +116,7 @@ describe('claude-provider.ts', () => {
const generator = provider.executeQuery({
prompt: 'Test',
model: 'claude-opus-4-5-20251101',
model: 'claude-opus-4-6',
cwd: '/test',
abortController,
});
@@ -145,7 +145,7 @@ describe('claude-provider.ts', () => {
const generator = provider.executeQuery({
prompt: 'Current message',
model: 'claude-opus-4-5-20251101',
model: 'claude-opus-4-6',
cwd: '/test',
conversationHistory,
sdkSessionId: 'test-session-id',
@@ -176,7 +176,7 @@ describe('claude-provider.ts', () => {
const generator = provider.executeQuery({
prompt: arrayPrompt as any,
model: 'claude-opus-4-5-20251101',
model: 'claude-opus-4-6',
cwd: '/test',
});
@@ -187,7 +187,7 @@ describe('claude-provider.ts', () => {
expect(typeof callArgs.prompt).not.toBe('string');
});
it('should use maxTurns default of 20', async () => {
it('should use maxTurns default of 100', async () => {
vi.mocked(sdk.query).mockReturnValue(
(async function* () {
yield { type: 'text', text: 'test' };
@@ -196,7 +196,7 @@ describe('claude-provider.ts', () => {
const generator = provider.executeQuery({
prompt: 'Test',
model: 'claude-opus-4-5-20251101',
model: 'claude-opus-4-6',
cwd: '/test',
});
@@ -205,7 +205,7 @@ describe('claude-provider.ts', () => {
expect(sdk.query).toHaveBeenCalledWith({
prompt: 'Test',
options: expect.objectContaining({
maxTurns: 20,
maxTurns: 100,
}),
});
});
@@ -222,7 +222,7 @@ describe('claude-provider.ts', () => {
const generator = provider.executeQuery({
prompt: 'Test',
model: 'claude-opus-4-5-20251101',
model: 'claude-opus-4-6',
cwd: '/test',
});
@@ -286,7 +286,7 @@ describe('claude-provider.ts', () => {
const generator = provider.executeQuery({
prompt: 'Test',
model: 'claude-opus-4-5-20251101',
model: 'claude-opus-4-6',
cwd: '/test',
});
@@ -313,7 +313,7 @@ describe('claude-provider.ts', () => {
const generator = provider.executeQuery({
prompt: 'Test',
model: 'claude-opus-4-5-20251101',
model: 'claude-opus-4-6',
cwd: '/test',
});
@@ -341,7 +341,7 @@ describe('claude-provider.ts', () => {
const generator = provider.executeQuery({
prompt: 'Test',
model: 'claude-opus-4-5-20251101',
model: 'claude-opus-4-6',
cwd: '/test',
});
@@ -366,12 +366,12 @@ describe('claude-provider.ts', () => {
expect(models).toHaveLength(4);
});
it('should include Claude Opus 4.5', () => {
it('should include Claude Opus 4.6', () => {
const models = provider.getAvailableModels();
const opus = models.find((m) => m.id === 'claude-opus-4-5-20251101');
const opus = models.find((m) => m.id === 'claude-opus-4-6');
expect(opus).toBeDefined();
expect(opus?.name).toBe('Claude Opus 4.5');
expect(opus?.name).toBe('Claude Opus 4.6');
expect(opus?.provider).toBe('anthropic');
});
@@ -400,7 +400,7 @@ describe('claude-provider.ts', () => {
it('should mark Opus as default', () => {
const models = provider.getAvailableModels();
const opus = models.find((m) => m.id === 'claude-opus-4-5-20251101');
const opus = models.find((m) => m.id === 'claude-opus-4-6');
expect(opus?.default).toBe(true);
});

View File

@@ -54,8 +54,8 @@ describe('provider-factory.ts', () => {
describe('getProviderForModel', () => {
describe('Claude models (claude-* prefix)', () => {
it('should return ClaudeProvider for claude-opus-4-5-20251101', () => {
const provider = ProviderFactory.getProviderForModel('claude-opus-4-5-20251101');
it('should return ClaudeProvider for claude-opus-4-6', () => {
const provider = ProviderFactory.getProviderForModel('claude-opus-4-6');
expect(provider).toBeInstanceOf(ClaudeProvider);
});
@@ -70,7 +70,7 @@ describe('provider-factory.ts', () => {
});
it('should be case-insensitive for claude models', () => {
const provider = ProviderFactory.getProviderForModel('CLAUDE-OPUS-4-5-20251101');
const provider = ProviderFactory.getProviderForModel('CLAUDE-OPUS-4-6');
expect(provider).toBeInstanceOf(ClaudeProvider);
});
});

View File

@@ -0,0 +1,106 @@
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import type { Request, Response } from 'express';
import { createMockExpressContext } from '../../../utils/mocks.js';
vi.mock('child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('child_process')>();
return {
...actual,
exec: vi.fn(),
};
});
vi.mock('util', async (importOriginal) => {
const actual = await importOriginal<typeof import('util')>();
return {
...actual,
promisify: (fn: unknown) => fn,
};
});
import { exec } from 'child_process';
import { createSwitchBranchHandler } from '@/routes/worktree/routes/switch-branch.js';
const mockExec = exec as Mock;
describe('switch-branch route', () => {
let req: Request;
let res: Response;
beforeEach(() => {
vi.clearAllMocks();
const context = createMockExpressContext();
req = context.req;
res = context.res;
});
it('should allow switching when only untracked files exist', async () => {
req.body = {
worktreePath: '/repo/path',
branchName: 'feature/test',
};
mockExec.mockImplementation(async (command: string) => {
if (command === 'git rev-parse --abbrev-ref HEAD') {
return { stdout: 'main\n', stderr: '' };
}
if (command === 'git rev-parse --verify feature/test') {
return { stdout: 'abc123\n', stderr: '' };
}
if (command === 'git status --porcelain') {
return { stdout: '?? .automaker/\n?? notes.txt\n', stderr: '' };
}
if (command === 'git checkout "feature/test"') {
return { stdout: '', stderr: '' };
}
return { stdout: '', stderr: '' };
});
const handler = createSwitchBranchHandler();
await handler(req, res);
expect(res.json).toHaveBeenCalledWith({
success: true,
result: {
previousBranch: 'main',
currentBranch: 'feature/test',
message: "Switched to branch 'feature/test'",
},
});
expect(mockExec).toHaveBeenCalledWith('git checkout "feature/test"', { cwd: '/repo/path' });
});
it('should block switching when tracked files are modified', async () => {
req.body = {
worktreePath: '/repo/path',
branchName: 'feature/test',
};
mockExec.mockImplementation(async (command: string) => {
if (command === 'git rev-parse --abbrev-ref HEAD') {
return { stdout: 'main\n', stderr: '' };
}
if (command === 'git rev-parse --verify feature/test') {
return { stdout: 'abc123\n', stderr: '' };
}
if (command === 'git status --porcelain') {
return { stdout: ' M src/index.ts\n?? notes.txt\n', stderr: '' };
}
if (command === 'git status --short') {
return { stdout: ' M src/index.ts\n?? notes.txt\n', stderr: '' };
}
return { stdout: '', stderr: '' };
});
const handler = createSwitchBranchHandler();
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error:
'Cannot switch branches: you have uncommitted changes (M src/index.ts). Please commit your changes first.',
code: 'UNCOMMITTED_CHANGES',
});
});
});

View File

@@ -177,6 +177,66 @@ describe('claude-usage-service.ts', () => {
// BEL is stripped, newlines and tabs preserved
expect(result).toBe('Line 1\nLine 2\tTabbed with bell');
});
it('should convert cursor forward (ESC[nC) to spaces', () => {
const service = new ClaudeUsageService();
// Claude CLI TUI uses ESC[1C instead of space between words
const input = 'Current\x1B[1Csession';
// @ts-expect-error - accessing private method for testing
const result = service.stripAnsiCodes(input);
expect(result).toBe('Current session');
});
it('should handle multi-character cursor forward sequences', () => {
const service = new ClaudeUsageService();
// ESC[3C = move cursor forward 3 positions = 3 spaces
const input = 'Hello\x1B[3Cworld';
// @ts-expect-error - accessing private method for testing
const result = service.stripAnsiCodes(input);
expect(result).toBe('Hello world');
});
it('should handle real Claude CLI TUI output with cursor movement codes', () => {
const service = new ClaudeUsageService();
// Simulates actual Claude CLI /usage output where words are separated by ESC[1C
const input =
'Current\x1B[1Cweek\x1B[1C(all\x1B[1Cmodels)\n' +
'\x1B[32m█████████████████████████▌\x1B[0m\x1B[1C51%\x1B[1Cused\n' +
'Resets\x1B[1CFeb\x1B[1C19\x1B[1Cat\x1B[1C3pm\x1B[1C(America/Los_Angeles)';
// @ts-expect-error - accessing private method for testing
const result = service.stripAnsiCodes(input);
expect(result).toContain('Current week (all models)');
expect(result).toContain('51% used');
expect(result).toContain('Resets Feb 19 at 3pm (America/Los_Angeles)');
});
it('should parse usage output with cursor movement codes between words', () => {
const service = new ClaudeUsageService();
// Simulates the full /usage TUI output with ESC[1C between every word
const output =
'Current\x1B[1Csession\n' +
'\x1B[32m█████████████▌\x1B[0m\x1B[1C27%\x1B[1Cused\n' +
'Resets\x1B[1C9pm\x1B[1C(America/Los_Angeles)\n' +
'\n' +
'Current\x1B[1Cweek\x1B[1C(all\x1B[1Cmodels)\n' +
'\x1B[32m█████████████████████████▌\x1B[0m\x1B[1C51%\x1B[1Cused\n' +
'Resets\x1B[1CFeb\x1B[1C19\x1B[1Cat\x1B[1C3pm\x1B[1C(America/Los_Angeles)\n' +
'\n' +
'Current\x1B[1Cweek\x1B[1C(Sonnet\x1B[1Conly)\n' +
'\x1B[32m██▌\x1B[0m\x1B[1C5%\x1B[1Cused\n' +
'Resets\x1B[1CFeb\x1B[1C19\x1B[1Cat\x1B[1C11pm\x1B[1C(America/Los_Angeles)';
// @ts-expect-error - accessing private method for testing
const result = service.parseUsageOutput(output);
expect(result.sessionPercentage).toBe(27);
expect(result.weeklyPercentage).toBe(51);
expect(result.sonnetWeeklyPercentage).toBe(5);
expect(result.weeklyResetText).toContain('Resets Feb 19 at 3pm');
expect(result.weeklyResetText).not.toContain('America/Los_Angeles');
});
});
describe('parseResetTime', () => {

View File

@@ -380,6 +380,148 @@ describe('dev-server-service.ts', () => {
expect(service.listDevServers().result.servers).toHaveLength(0);
});
});
describe('URL detection from output', () => {
it('should detect Vite format URL', async () => {
vi.mocked(secureFs.access).mockResolvedValue(undefined);
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
// Start server
await service.startDevServer(testDir, testDir);
// Simulate Vite output
mockProcess.stdout.emit('data', Buffer.from(' VITE v5.0.0 ready in 123 ms\n'));
mockProcess.stdout.emit('data', Buffer.from(' ➜ Local: http://localhost:5173/\n'));
// Give it a moment to process
await new Promise((resolve) => setTimeout(resolve, 50));
const serverInfo = service.getServerInfo(testDir);
expect(serverInfo?.url).toBe('http://localhost:5173/');
expect(serverInfo?.urlDetected).toBe(true);
});
it('should detect Next.js format URL', async () => {
vi.mocked(secureFs.access).mockResolvedValue(undefined);
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.startDevServer(testDir, testDir);
// Simulate Next.js output
mockProcess.stdout.emit(
'data',
Buffer.from('ready - started server on 0.0.0.0:3000, url: http://localhost:3000\n')
);
await new Promise((resolve) => setTimeout(resolve, 50));
const serverInfo = service.getServerInfo(testDir);
expect(serverInfo?.url).toBe('http://localhost:3000');
expect(serverInfo?.urlDetected).toBe(true);
});
it('should detect generic localhost URL', async () => {
vi.mocked(secureFs.access).mockResolvedValue(undefined);
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.startDevServer(testDir, testDir);
// Simulate generic output with URL
mockProcess.stdout.emit('data', Buffer.from('Server running at http://localhost:8080\n'));
await new Promise((resolve) => setTimeout(resolve, 50));
const serverInfo = service.getServerInfo(testDir);
expect(serverInfo?.url).toBe('http://localhost:8080');
expect(serverInfo?.urlDetected).toBe(true);
});
it('should keep initial URL if no URL detected in output', async () => {
vi.mocked(secureFs.access).mockResolvedValue(undefined);
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
const result = await service.startDevServer(testDir, testDir);
// Simulate output without URL
mockProcess.stdout.emit('data', Buffer.from('Server starting...\n'));
mockProcess.stdout.emit('data', Buffer.from('Ready!\n'));
await new Promise((resolve) => setTimeout(resolve, 50));
const serverInfo = service.getServerInfo(testDir);
// Should keep the initial allocated URL
expect(serverInfo?.url).toBe(result.result?.url);
expect(serverInfo?.urlDetected).toBe(false);
});
it('should detect HTTPS URLs', async () => {
vi.mocked(secureFs.access).mockResolvedValue(undefined);
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.startDevServer(testDir, testDir);
// Simulate HTTPS dev server
mockProcess.stdout.emit('data', Buffer.from('Server at https://localhost:3443\n'));
await new Promise((resolve) => setTimeout(resolve, 50));
const serverInfo = service.getServerInfo(testDir);
expect(serverInfo?.url).toBe('https://localhost:3443');
expect(serverInfo?.urlDetected).toBe(true);
});
it('should only detect URL once (not update after first detection)', async () => {
vi.mocked(secureFs.access).mockResolvedValue(undefined);
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.startDevServer(testDir, testDir);
// First URL
mockProcess.stdout.emit('data', Buffer.from('Local: http://localhost:5173/\n'));
await new Promise((resolve) => setTimeout(resolve, 50));
const firstUrl = service.getServerInfo(testDir)?.url;
// Try to emit another URL
mockProcess.stdout.emit('data', Buffer.from('Network: http://192.168.1.1:5173/\n'));
await new Promise((resolve) => setTimeout(resolve, 50));
// Should keep the first detected URL
const serverInfo = service.getServerInfo(testDir);
expect(serverInfo?.url).toBe(firstUrl);
expect(serverInfo?.url).toBe('http://localhost:5173/');
});
});
});
// Helper to create a mock child process

View File

@@ -677,6 +677,302 @@ describe('execution-service.ts', () => {
});
});
describe('executeFeature - incomplete task retry', () => {
const createServiceWithMocks = () => {
return new ExecutionService(
mockEventBus,
mockConcurrencyManager,
mockWorktreeResolver,
mockSettingsService,
mockRunAgentFn,
mockExecutePipelineFn,
mockUpdateFeatureStatusFn,
mockLoadFeatureFn,
mockGetPlanningPromptPrefixFn,
mockSaveFeatureSummaryFn,
mockRecordLearningsFn,
mockContextExistsFn,
mockResumeFeatureFn,
mockTrackFailureFn,
mockSignalPauseFn,
mockRecordSuccessFn,
mockSaveExecutionStateFn,
mockLoadContextFilesFn
);
};
it('does not re-run agent when feature has no tasks', async () => {
// Feature with no planSpec/tasks - should complete normally with 1 agent call
mockLoadFeatureFn = vi.fn().mockResolvedValue(testFeature);
const svc = createServiceWithMocks();
await svc.executeFeature('/test/project', 'feature-1');
expect(mockRunAgentFn).toHaveBeenCalledTimes(1);
});
it('does not re-run agent when all tasks are completed', async () => {
const featureWithCompletedTasks: Feature = {
...testFeature,
planSpec: {
status: 'approved',
content: 'Plan',
tasks: [
{ id: 'T001', title: 'Task 1', status: 'completed', description: 'First task' },
{ id: 'T002', title: 'Task 2', status: 'completed', description: 'Second task' },
],
tasksCompleted: 2,
},
};
mockLoadFeatureFn = vi.fn().mockResolvedValue(featureWithCompletedTasks);
const svc = createServiceWithMocks();
await svc.executeFeature('/test/project', 'feature-1');
// Only the initial agent call + the approved-plan recursive call
// The approved plan triggers recursive executeFeature, so runAgentFn is called once in the inner call
expect(mockRunAgentFn).toHaveBeenCalledTimes(1);
});
it('re-runs agent when there are pending tasks after initial execution', async () => {
const featureWithPendingTasks: Feature = {
...testFeature,
planSpec: {
status: 'approved',
content: 'Plan',
tasks: [
{ id: 'T001', title: 'Task 1', status: 'completed', description: 'First task' },
{ id: 'T002', title: 'Task 2', status: 'pending', description: 'Second task' },
{ id: 'T003', title: 'Task 3', status: 'pending', description: 'Third task' },
],
tasksCompleted: 1,
},
};
// After first agent run, loadFeature returns feature with pending tasks
// After second agent run, loadFeature returns feature with all tasks completed
const featureAllDone: Feature = {
...testFeature,
planSpec: {
status: 'approved',
content: 'Plan',
tasks: [
{ id: 'T001', title: 'Task 1', status: 'completed', description: 'First task' },
{ id: 'T002', title: 'Task 2', status: 'completed', description: 'Second task' },
{ id: 'T003', title: 'Task 3', status: 'completed', description: 'Third task' },
],
tasksCompleted: 3,
},
};
let loadCallCount = 0;
mockLoadFeatureFn = vi.fn().mockImplementation(() => {
loadCallCount++;
// First call: initial feature load at the top of executeFeature
// Second call: after first agent run (check for incomplete tasks) - has pending tasks
// Third call: after second agent run (check for incomplete tasks) - all done
if (loadCallCount <= 2) return featureWithPendingTasks;
return featureAllDone;
});
const svc = createServiceWithMocks();
await svc.executeFeature('/test/project', 'feature-1', false, false, undefined, {
continuationPrompt: 'Continue',
_calledInternally: true,
});
// Should have called runAgentFn twice: initial + one retry
expect(mockRunAgentFn).toHaveBeenCalledTimes(2);
// The retry call should contain continuation prompt about incomplete tasks
const retryCallArgs = mockRunAgentFn.mock.calls[1];
expect(retryCallArgs[2]).toContain('Continue Implementation - Incomplete Tasks');
expect(retryCallArgs[2]).toContain('T002');
expect(retryCallArgs[2]).toContain('T003');
// Should have emitted a progress event about retrying
expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith(
'auto_mode_progress',
expect.objectContaining({
featureId: 'feature-1',
content: expect.stringContaining('Re-running to complete tasks'),
})
);
});
it('respects maximum retry attempts', async () => {
const featureAlwaysPending: Feature = {
...testFeature,
planSpec: {
status: 'approved',
content: 'Plan',
tasks: [
{ id: 'T001', title: 'Task 1', status: 'completed', description: 'First task' },
{ id: 'T002', title: 'Task 2', status: 'pending', description: 'Second task' },
],
tasksCompleted: 1,
},
};
// Always return feature with pending tasks (agent never completes T002)
mockLoadFeatureFn = vi.fn().mockResolvedValue(featureAlwaysPending);
const svc = createServiceWithMocks();
await svc.executeFeature('/test/project', 'feature-1', false, false, undefined, {
continuationPrompt: 'Continue',
_calledInternally: true,
});
// Initial run + 3 retry attempts = 4 total
expect(mockRunAgentFn).toHaveBeenCalledTimes(4);
// Should still set final status even with incomplete tasks
expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith(
'/test/project',
'feature-1',
'verified'
);
});
it('stops retrying when abort signal is triggered', async () => {
const featureWithPendingTasks: Feature = {
...testFeature,
planSpec: {
status: 'approved',
content: 'Plan',
tasks: [
{ id: 'T001', title: 'Task 1', status: 'completed', description: 'First task' },
{ id: 'T002', title: 'Task 2', status: 'pending', description: 'Second task' },
],
tasksCompleted: 1,
},
};
mockLoadFeatureFn = vi.fn().mockResolvedValue(featureWithPendingTasks);
// Simulate abort after first agent run
let runCount = 0;
const capturedAbortController = { current: null as AbortController | null };
mockRunAgentFn = vi.fn().mockImplementation((_wd, _fid, _prompt, abortCtrl) => {
capturedAbortController.current = abortCtrl;
runCount++;
if (runCount >= 1) {
// Abort after first run
abortCtrl.abort();
}
return Promise.resolve();
});
const svc = createServiceWithMocks();
await svc.executeFeature('/test/project', 'feature-1', false, false, undefined, {
continuationPrompt: 'Continue',
_calledInternally: true,
});
// Should only have the initial run, then abort prevents retries
expect(mockRunAgentFn).toHaveBeenCalledTimes(1);
});
it('re-runs agent for in_progress tasks (not just pending)', async () => {
const featureWithInProgressTask: Feature = {
...testFeature,
planSpec: {
status: 'approved',
content: 'Plan',
tasks: [
{ id: 'T001', title: 'Task 1', status: 'completed', description: 'First task' },
{ id: 'T002', title: 'Task 2', status: 'in_progress', description: 'Second task' },
],
tasksCompleted: 1,
currentTaskId: 'T002',
},
};
const featureAllDone: Feature = {
...testFeature,
planSpec: {
status: 'approved',
content: 'Plan',
tasks: [
{ id: 'T001', title: 'Task 1', status: 'completed', description: 'First task' },
{ id: 'T002', title: 'Task 2', status: 'completed', description: 'Second task' },
],
tasksCompleted: 2,
},
};
let loadCallCount = 0;
mockLoadFeatureFn = vi.fn().mockImplementation(() => {
loadCallCount++;
if (loadCallCount <= 2) return featureWithInProgressTask;
return featureAllDone;
});
const svc = createServiceWithMocks();
await svc.executeFeature('/test/project', 'feature-1', false, false, undefined, {
continuationPrompt: 'Continue',
_calledInternally: true,
});
// Should have retried for the in_progress task
expect(mockRunAgentFn).toHaveBeenCalledTimes(2);
// The retry prompt should mention the in_progress task
const retryCallArgs = mockRunAgentFn.mock.calls[1];
expect(retryCallArgs[2]).toContain('T002');
expect(retryCallArgs[2]).toContain('in_progress');
});
it('uses planningMode skip and no plan approval for retry runs', async () => {
const featureWithPendingTasks: Feature = {
...testFeature,
planningMode: 'full',
requirePlanApproval: true,
planSpec: {
status: 'approved',
content: 'Plan',
tasks: [
{ id: 'T001', title: 'Task 1', status: 'completed', description: 'First task' },
{ id: 'T002', title: 'Task 2', status: 'pending', description: 'Second task' },
],
tasksCompleted: 1,
},
};
const featureAllDone: Feature = {
...testFeature,
planSpec: {
status: 'approved',
content: 'Plan',
tasks: [
{ id: 'T001', title: 'Task 1', status: 'completed', description: 'First task' },
{ id: 'T002', title: 'Task 2', status: 'completed', description: 'Second task' },
],
tasksCompleted: 2,
},
};
let loadCallCount = 0;
mockLoadFeatureFn = vi.fn().mockImplementation(() => {
loadCallCount++;
if (loadCallCount <= 2) return featureWithPendingTasks;
return featureAllDone;
});
const svc = createServiceWithMocks();
await svc.executeFeature('/test/project', 'feature-1', false, false, undefined, {
continuationPrompt: 'Continue',
_calledInternally: true,
});
// The retry agent call should use planningMode: 'skip' and requirePlanApproval: false
const retryCallArgs = mockRunAgentFn.mock.calls[1];
const retryOptions = retryCallArgs[7]; // options object
expect(retryOptions.planningMode).toBe('skip');
expect(retryOptions.requirePlanApproval).toBe(false);
});
});
describe('executeFeature - error handling', () => {
it('classifies and emits error event', async () => {
const testError = new Error('Test error');

View File

@@ -151,6 +151,100 @@ describe('FeatureStateManager', () => {
expect(savedFeature.justFinishedAt).toBeUndefined();
});
it('should finalize in_progress tasks but keep pending tasks when moving to waiting_approval', async () => {
const featureWithTasks: Feature = {
...mockFeature,
status: 'in_progress',
planSpec: {
status: 'approved',
version: 1,
reviewedByUser: true,
currentTaskId: 'task-2',
tasksCompleted: 1,
tasks: [
{ id: 'task-1', title: 'Task 1', status: 'completed', description: 'First task' },
{ id: 'task-2', title: 'Task 2', status: 'in_progress', description: 'Second task' },
{ id: 'task-3', title: 'Task 3', status: 'pending', description: 'Third task' },
],
},
};
(readJsonWithRecovery as Mock).mockResolvedValue({
data: featureWithTasks,
recovered: false,
source: 'main',
});
await manager.updateFeatureStatus('/project', 'feature-123', 'waiting_approval');
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
// Already completed tasks stay completed
expect(savedFeature.planSpec?.tasks?.[0].status).toBe('completed');
// in_progress tasks should be finalized to completed
expect(savedFeature.planSpec?.tasks?.[1].status).toBe('completed');
// pending tasks should remain pending (never started)
expect(savedFeature.planSpec?.tasks?.[2].status).toBe('pending');
// currentTaskId should be cleared
expect(savedFeature.planSpec?.currentTaskId).toBeUndefined();
// tasksCompleted should equal actual completed tasks count
expect(savedFeature.planSpec?.tasksCompleted).toBe(2);
});
it('should finalize tasks when moving to verified status', async () => {
const featureWithTasks: Feature = {
...mockFeature,
status: 'in_progress',
planSpec: {
status: 'approved',
version: 1,
reviewedByUser: true,
currentTaskId: 'task-2',
tasksCompleted: 1,
tasks: [
{ id: 'task-1', title: 'Task 1', status: 'completed', description: 'First task' },
{ id: 'task-2', title: 'Task 2', status: 'in_progress', description: 'Second task' },
{ id: 'task-3', title: 'Task 3', status: 'pending', description: 'Third task' },
],
},
};
(readJsonWithRecovery as Mock).mockResolvedValue({
data: featureWithTasks,
recovered: false,
source: 'main',
});
await manager.updateFeatureStatus('/project', 'feature-123', 'verified');
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
// Already completed tasks stay completed
expect(savedFeature.planSpec?.tasks?.[0].status).toBe('completed');
// in_progress tasks should be finalized to completed
expect(savedFeature.planSpec?.tasks?.[1].status).toBe('completed');
// pending tasks should remain pending (never started)
expect(savedFeature.planSpec?.tasks?.[2].status).toBe('pending');
// currentTaskId should be cleared
expect(savedFeature.planSpec?.currentTaskId).toBeUndefined();
// tasksCompleted should equal actual completed tasks count
expect(savedFeature.planSpec?.tasksCompleted).toBe(2);
// justFinishedAt should be cleared for verified
expect(savedFeature.justFinishedAt).toBeUndefined();
});
it('should handle waiting_approval without planSpec tasks gracefully', async () => {
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...mockFeature },
recovered: false,
source: 'main',
});
await manager.updateFeatureStatus('/project', 'feature-123', 'waiting_approval');
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
expect(savedFeature.status).toBe('waiting_approval');
expect(savedFeature.justFinishedAt).toBeDefined();
});
it('should create notification for waiting_approval status', async () => {
const mockNotificationService = { createNotification: vi.fn() };
(getNotificationService as Mock).mockReturnValue(mockNotificationService);

View File

@@ -165,6 +165,7 @@ describe('PipelineOrchestrator', () => {
mockWorktreeResolver = {
findWorktreeForBranch: vi.fn().mockResolvedValue('/test/worktree'),
getCurrentBranch: vi.fn().mockResolvedValue('main'),
} as unknown as WorktreeResolver;
mockConcurrencyManager = {

View File

@@ -199,7 +199,7 @@ The agent is configured with:
```javascript
{
model: "claude-opus-4-5-20251101",
model: "claude-opus-4-6",
maxTurns: 20,
cwd: workingDirectory,
allowedTools: [

View File

@@ -96,6 +96,7 @@ const eslintConfig = defineConfig([
setInterval: 'readonly',
clearTimeout: 'readonly',
clearInterval: 'readonly',
queueMicrotask: 'readonly',
// Node.js (for scripts and Electron)
process: 'readonly',
require: 'readonly',
@@ -118,7 +119,15 @@ const eslintConfig = defineConfig([
},
rules: {
...ts.configs.recommended.rules,
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
ignoreRestSiblings: true,
},
],
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/ban-ts-comment': [
'error',

View File

@@ -69,6 +69,29 @@ export function SandboxRiskDialog({ open, onConfirm, onDeny }: SandboxRiskDialog
For safer operation, consider running Automaker in Docker. See the README for
instructions.
</p>
<div className="bg-muted/50 border border-border rounded-lg p-4 space-y-2">
<p className="text-sm font-medium text-foreground">
Already running in Docker? Try these troubleshooting steps:
</p>
<ul className="text-sm text-muted-foreground list-disc list-inside space-y-1">
<li>
Ensure <code className="bg-muted px-1 rounded">IS_CONTAINERIZED=true</code> is
set in your docker-compose environment
</li>
<li>
Verify the server container has the environment variable:{' '}
<code className="bg-muted px-1 rounded">
docker exec automaker-server printenv IS_CONTAINERIZED
</code>
</li>
<li>Rebuild and restart containers if you recently changed the configuration</li>
<li>
Check the server logs for startup messages:{' '}
<code className="bg-muted px-1 rounded">docker-compose logs server</code>
</li>
</ul>
</div>
</div>
</DialogDescription>
</DialogHeader>

View File

@@ -405,9 +405,28 @@ export function Sidebar() {
</div>
{/* Scroll indicator - shows there's more content below */}
{canScrollDown && sidebarOpen && (
<div className="flex justify-center py-1 border-t border-border/30">
<ChevronDown className="w-4 h-4 text-muted-foreground/50 animate-bounce" />
{canScrollDown && (
<div
className={cn(
'relative flex justify-center py-2 border-t border-border/30',
'bg-gradient-to-t from-background via-background/95 to-transparent',
'-mt-8 pt-8',
'pointer-events-none'
)}
>
<div className="pointer-events-auto flex flex-col items-center gap-0.5">
<ChevronDown
className={cn(
'w-4 h-4 text-brand-500/70 animate-bounce',
sidebarOpen ? 'block' : 'w-3 h-3'
)}
/>
{sidebarOpen && (
<span className="text-[10px] font-medium text-muted-foreground/70 uppercase tracking-wide">
Scroll
</span>
)}
</div>
</div>
)}

View File

@@ -59,24 +59,19 @@ export function TaskProgressPanel({
const planSpec = feature.planSpec;
const planTasks = planSpec.tasks; // Already guarded by the if condition above
const currentId = planSpec.currentTaskId;
const completedCount = planSpec.tasksCompleted || 0;
// Convert planSpec tasks to TaskInfo with proper status
// Convert planSpec tasks to TaskInfo using their persisted status
// planTasks is guaranteed to be defined due to the if condition check
const initialTasks: TaskInfo[] = (planTasks as ParsedTask[]).map(
(t: ParsedTask, index: number) => ({
id: t.id,
description: t.description,
filePath: t.filePath,
phase: t.phase,
status:
index < completedCount
? ('completed' as const)
: t.id === currentId
? ('in_progress' as const)
: ('pending' as const),
})
);
const initialTasks: TaskInfo[] = (planTasks as ParsedTask[]).map((t: ParsedTask) => ({
id: t.id,
description: t.description,
filePath: t.filePath,
phase: t.phase,
status:
t.id === currentId
? ('in_progress' as const)
: (t.status as TaskInfo['status']) || ('pending' as const),
}));
setTasks(initialTasks);
setCurrentTaskId(currentId || null);
@@ -113,16 +108,12 @@ export function TaskProgressPanel({
const existingIndex = prev.findIndex((t) => t.id === taskEvent.taskId);
if (existingIndex !== -1) {
// Update status to in_progress and mark previous as completed
return prev.map((t, idx) => {
// Update only the started task to in_progress
// Do NOT assume previous tasks are completed - rely on actual task_complete events
return prev.map((t) => {
if (t.id === taskEvent.taskId) {
return { ...t, status: 'in_progress' as const };
}
// If we are moving to a task that is further down the list, assume previous ones are completed
// This is a heuristic, but usually correct for sequential execution
if (idx < existingIndex && t.status !== 'completed') {
return { ...t, status: 'completed' as const };
}
return t;
});
}
@@ -151,6 +142,24 @@ export function TaskProgressPanel({
setCurrentTaskId(null);
}
break;
case 'auto_mode_task_status':
if ('taskId' in event && 'status' in event) {
const taskEvent = event as Extract<AutoModeEvent, { type: 'auto_mode_task_status' }>;
setTasks((prev) =>
prev.map((t) =>
t.id === taskEvent.taskId
? { ...t, status: taskEvent.status as TaskInfo['status'] }
: t
)
);
if (taskEvent.status === 'in_progress') {
setCurrentTaskId(taskEvent.taskId);
} else if (taskEvent.status === 'completed') {
setCurrentTaskId((current) => (current === taskEvent.taskId ? null : current));
}
}
break;
}
});

View File

@@ -8,6 +8,7 @@ import { cn } from '@/lib/utils';
import { useSetupStore } from '@/store/setup-store';
import { AnthropicIcon, OpenAIIcon } from '@/components/ui/provider-icon';
import { useClaudeUsage, useCodexUsage } from '@/hooks/queries';
import { getExpectedWeeklyPacePercentage, getPaceStatusLabel } from '@/store/utils/usage-utils';
// Error codes for distinguishing failure modes
const ERROR_CODES = {
@@ -146,13 +147,28 @@ export function UsagePopover() {
return { color: 'text-green-500', icon: CheckCircle, bg: 'bg-green-500' };
};
// Helper component for the progress bar
const ProgressBar = ({ percentage, colorClass }: { percentage: number; colorClass: string }) => (
<div className="h-2 w-full bg-secondary/50 rounded-full overflow-hidden">
// Helper component for the progress bar with optional pace indicator
const ProgressBar = ({
percentage,
colorClass,
pacePercentage,
}: {
percentage: number;
colorClass: string;
pacePercentage?: number | null;
}) => (
<div className="relative h-2 w-full bg-secondary/50 rounded-full overflow-hidden">
<div
className={cn('h-full transition-all duration-500', colorClass)}
style={{ width: `${Math.min(percentage, 100)}%` }}
/>
{pacePercentage != null && pacePercentage > 0 && pacePercentage < 100 && (
<div
className="absolute top-0 h-full w-0.5 bg-foreground/60"
style={{ left: `${pacePercentage}%` }}
title={`Expected: ${Math.round(pacePercentage)}%`}
/>
)}
</div>
);
@@ -163,6 +179,7 @@ export function UsagePopover() {
resetText,
isPrimary = false,
stale = false,
pacePercentage,
}: {
title: string;
subtitle: string;
@@ -170,6 +187,7 @@ export function UsagePopover() {
resetText?: string;
isPrimary?: boolean;
stale?: boolean;
pacePercentage?: number | null;
}) => {
const isValidPercentage =
typeof percentage === 'number' && !isNaN(percentage) && isFinite(percentage);
@@ -177,6 +195,10 @@ export function UsagePopover() {
const status = getStatusInfo(safePercentage);
const StatusIcon = status.icon;
const paceLabel =
isValidPercentage && pacePercentage != null
? getPaceStatusLabel(safePercentage, pacePercentage)
: null;
return (
<div
@@ -211,15 +233,28 @@ export function UsagePopover() {
<ProgressBar
percentage={safePercentage}
colorClass={isValidPercentage ? status.bg : 'bg-muted-foreground/30'}
pacePercentage={pacePercentage}
/>
{resetText && (
<div className="mt-2 flex justify-end">
<div className="mt-2 flex items-center justify-between">
{paceLabel ? (
<p
className={cn(
'text-[10px] font-medium',
safePercentage > (pacePercentage ?? 0) ? 'text-orange-500' : 'text-green-500'
)}
>
{paceLabel}
</p>
) : (
<div />
)}
{resetText && (
<p className="text-xs text-muted-foreground flex items-center gap-1">
<Clock className="w-3 h-3" />
{resetText}
</p>
</div>
)}
)}
</div>
</div>
);
};
@@ -384,6 +419,7 @@ export function UsagePopover() {
percentage={claudeUsage.sonnetWeeklyPercentage}
resetText={claudeUsage.sonnetResetText}
stale={isClaudeStale}
pacePercentage={getExpectedWeeklyPacePercentage(claudeUsage.weeklyResetTime)}
/>
<UsageCard
title="Weekly"
@@ -391,6 +427,7 @@ export function UsagePopover() {
percentage={claudeUsage.weeklyPercentage}
resetText={claudeUsage.weeklyResetText}
stale={isClaudeStale}
pacePercentage={getExpectedWeeklyPacePercentage(claudeUsage.weeklyResetTime)}
/>
</div>

View File

@@ -437,6 +437,63 @@ export function BoardView() {
// Auto mode hook - pass current worktree to get worktree-specific state
// Must be after selectedWorktree is defined
const autoMode = useAutoMode(selectedWorktree);
const refreshBoardState = useCallback(async () => {
if (!currentProject) return;
const projectPath = currentProject.path;
const beforeFeatures = (
queryClient.getQueryData(queryKeys.features.all(projectPath)) as Feature[] | undefined
)?.length;
const beforeWorktrees = (
queryClient.getQueryData(queryKeys.worktrees.all(projectPath)) as
| { worktrees?: unknown[] }
| undefined
)?.worktrees?.length;
const beforeRunningAgents = (
queryClient.getQueryData(queryKeys.runningAgents.all()) as { count?: number } | undefined
)?.count;
const beforeAutoModeRunning = autoMode.isRunning;
try {
await Promise.all([
queryClient.refetchQueries({ queryKey: queryKeys.features.all(projectPath) }),
queryClient.refetchQueries({ queryKey: queryKeys.runningAgents.all() }),
queryClient.refetchQueries({ queryKey: queryKeys.worktrees.all(projectPath) }),
autoMode.refreshStatus(),
]);
const afterFeatures = (
queryClient.getQueryData(queryKeys.features.all(projectPath)) as Feature[] | undefined
)?.length;
const afterWorktrees = (
queryClient.getQueryData(queryKeys.worktrees.all(projectPath)) as
| { worktrees?: unknown[] }
| undefined
)?.worktrees?.length;
const afterRunningAgents = (
queryClient.getQueryData(queryKeys.runningAgents.all()) as { count?: number } | undefined
)?.count;
const afterAutoModeRunning = autoMode.isRunning;
if (
beforeFeatures !== afterFeatures ||
beforeWorktrees !== afterWorktrees ||
beforeRunningAgents !== afterRunningAgents ||
beforeAutoModeRunning !== afterAutoModeRunning
) {
logger.info('[Board] Refresh detected state mismatch', {
features: { before: beforeFeatures, after: afterFeatures },
worktrees: { before: beforeWorktrees, after: afterWorktrees },
runningAgents: { before: beforeRunningAgents, after: afterRunningAgents },
autoModeRunning: { before: beforeAutoModeRunning, after: afterAutoModeRunning },
});
}
} catch (error) {
logger.error('[Board] Failed to refresh board state:', error);
toast.error('Failed to refresh board state');
}
}, [autoMode, currentProject, queryClient]);
// Get runningTasks from the hook (scoped to current project/worktree)
const runningAutoTasks = autoMode.runningTasks;
// Get worktree-specific maxConcurrency from the hook
@@ -533,10 +590,11 @@ export function BoardView() {
handleForceStopFeature,
handleStartNextFeatures,
handleArchiveAllVerified,
handleDuplicateFeature,
} = useBoardActions({
currentProject,
features: hookFeatures,
runningAutoTasks,
runningAutoTasks: runningAutoTasksAllWorktrees,
loadFeatures,
persistFeatureCreate,
persistFeatureUpdate,
@@ -825,7 +883,15 @@ export function BoardView() {
// Capture existing feature IDs before adding
const featuresBeforeIds = new Set(useAppStore.getState().features.map((f) => f.id));
await handleAddFeature(featureData);
try {
await handleAddFeature(featureData);
} catch (error) {
logger.error('Failed to create PR comments feature:', error);
toast.error('Failed to create feature', {
description: error instanceof Error ? error.message : 'An error occurred',
});
return;
}
// Find the newly created feature by looking for an ID that wasn't in the original set
const latestFeatures = useAppStore.getState().features;
@@ -856,7 +922,7 @@ export function BoardView() {
// Create the feature
const featureData = {
title: `Resolve Merge Conflicts`,
title: `Resolve Merge Conflicts: ${remoteBranch}${worktree.branch}`,
category: 'Maintenance',
description,
images: [],
@@ -873,7 +939,15 @@ export function BoardView() {
// Capture existing feature IDs before adding
const featuresBeforeIds = new Set(useAppStore.getState().features.map((f) => f.id));
await handleAddFeature(featureData);
try {
await handleAddFeature(featureData);
} catch (error) {
logger.error('Failed to create resolve conflicts feature:', error);
toast.error('Failed to create feature', {
description: error instanceof Error ? error.message : 'An error occurred',
});
return;
}
// Find the newly created feature by looking for an ID that wasn't in the original set
const latestFeatures = useAppStore.getState().features;
@@ -915,7 +989,15 @@ export function BoardView() {
// Capture existing feature IDs before adding
const featuresBeforeIds = new Set(useAppStore.getState().features.map((f) => f.id));
await handleAddFeature(featureData);
try {
await handleAddFeature(featureData);
} catch (error) {
logger.error('Failed to create merge conflict resolution feature:', error);
toast.error('Failed to create feature', {
description: error instanceof Error ? error.message : 'An error occurred',
});
return;
}
// Find the newly created feature by looking for an ID that wasn't in the original set
const latestFeatures = useAppStore.getState().features;
@@ -938,7 +1020,15 @@ export function BoardView() {
async (featureData: Parameters<typeof handleAddFeature>[0]) => {
// Capture existing feature IDs before adding
const featuresBeforeIds = new Set(useAppStore.getState().features.map((f) => f.id));
await handleAddFeature(featureData);
try {
await handleAddFeature(featureData);
} catch (error) {
logger.error('Failed to create feature:', error);
toast.error('Failed to create feature', {
description: error instanceof Error ? error.message : 'An error occurred',
});
return;
}
// Find the newly created feature by looking for an ID that wasn't in the original set
const latestFeatures = useAppStore.getState().features;
@@ -1035,7 +1125,7 @@ export function BoardView() {
} = useBoardDragDrop({
features: hookFeatures,
currentProject,
runningAutoTasks,
runningAutoTasks: runningAutoTasksAllWorktrees,
persistFeatureUpdate,
handleStartImplementation,
});
@@ -1305,10 +1395,16 @@ export function BoardView() {
if (enabled) {
autoMode.start().catch((error) => {
logger.error('[AutoMode] Failed to start:', error);
toast.error('Failed to start auto mode', {
description: error instanceof Error ? error.message : 'Unknown error',
});
});
} else {
autoMode.stop().catch((error) => {
logger.error('[AutoMode] Failed to stop:', error);
toast.error('Failed to stop auto mode', {
description: error instanceof Error ? error.message : 'Unknown error',
});
});
}
}}
@@ -1321,6 +1417,7 @@ export function BoardView() {
isCreatingSpec={isCreatingSpec}
creatingSpecProjectPath={creatingSpecProjectPath}
onShowBoardBackground={() => setShowBoardBackgroundModal(true)}
onRefreshBoard={refreshBoardState}
viewMode={viewMode}
onViewModeChange={setViewMode}
/>
@@ -1407,8 +1504,10 @@ export function BoardView() {
setSpawnParentFeature(feature);
setShowAddDialog(true);
},
onDuplicate: (feature) => handleDuplicateFeature(feature, false),
onDuplicateAsChild: (feature) => handleDuplicateFeature(feature, true),
}}
runningAutoTasks={runningAutoTasks}
runningAutoTasks={runningAutoTasksAllWorktrees}
pipelineConfig={pipelineConfig}
onAddFeature={() => setShowAddDialog(true)}
isSelectionMode={isSelectionMode}
@@ -1446,8 +1545,10 @@ export function BoardView() {
setSpawnParentFeature(feature);
setShowAddDialog(true);
}}
onDuplicate={(feature) => handleDuplicateFeature(feature, false)}
onDuplicateAsChild={(feature) => handleDuplicateFeature(feature, true)}
featuresWithContext={featuresWithContext}
runningAutoTasks={runningAutoTasks}
runningAutoTasks={runningAutoTasksAllWorktrees}
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
onAddFeature={() => setShowAddDialog(true)}
onShowCompletedModal={() => setShowCompletedModal(true)}

View File

@@ -1,7 +1,9 @@
import { useCallback, useState } from 'react';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Wand2, GitBranch, ClipboardCheck } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Wand2, GitBranch, ClipboardCheck, RefreshCw } from 'lucide-react';
import { UsagePopover } from '@/components/usage-popover';
import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
@@ -35,6 +37,7 @@ interface BoardHeaderProps {
creatingSpecProjectPath?: string;
// Board controls props
onShowBoardBackground: () => void;
onRefreshBoard: () => Promise<void>;
// View toggle props
viewMode: ViewMode;
onViewModeChange: (mode: ViewMode) => void;
@@ -60,6 +63,7 @@ export function BoardHeader({
isCreatingSpec,
creatingSpecProjectPath,
onShowBoardBackground,
onRefreshBoard,
viewMode,
onViewModeChange,
}: BoardHeaderProps) {
@@ -110,9 +114,20 @@ export function BoardHeader({
// State for mobile actions panel
const [showActionsPanel, setShowActionsPanel] = useState(false);
const [isRefreshingBoard, setIsRefreshingBoard] = useState(false);
const isTablet = useIsTablet();
const handleRefreshBoard = useCallback(async () => {
if (isRefreshingBoard) return;
setIsRefreshingBoard(true);
try {
await onRefreshBoard();
} finally {
setIsRefreshingBoard(false);
}
}, [isRefreshingBoard, onRefreshBoard]);
return (
<div className="flex items-center justify-between gap-5 p-4 border-b border-border bg-glass backdrop-blur-md">
<div className="flex items-center gap-4">
@@ -127,6 +142,22 @@ export function BoardHeader({
<BoardControls isMounted={isMounted} onShowBoardBackground={onShowBoardBackground} />
</div>
<div className="flex gap-4 items-center">
{isMounted && !isTablet && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
size="icon-sm"
onClick={handleRefreshBoard}
disabled={isRefreshingBoard}
aria-label="Refresh board state from server"
>
<RefreshCw className={isRefreshingBoard ? 'w-4 h-4 animate-spin' : 'w-4 h-4'} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Refresh board state from server</TooltipContent>
</Tooltip>
)}
{/* Usage Popover - show if either provider is authenticated, only on desktop */}
{isMounted && !isTablet && (showClaudeUsage || showCodexUsage) && <UsagePopover />}

View File

@@ -22,6 +22,7 @@ function formatThinkingLevel(level: ThinkingLevel | undefined): string {
medium: 'Med',
high: 'High',
ultrathink: 'Ultra',
adaptive: 'Adaptive',
};
return labels[level];
}
@@ -152,6 +153,7 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
// Derive effective todos from planSpec.tasks when available, fallback to agentInfo.todos
// Uses freshPlanSpec (from API) for accurate progress, with taskStatusMap for real-time updates
const isFeatureFinished = feature.status === 'waiting_approval' || feature.status === 'verified';
const effectiveTodos = useMemo(() => {
// Use freshPlanSpec if available (fetched from API), fallback to store's feature.planSpec
const planSpec = freshPlanSpec?.tasks?.length ? freshPlanSpec : feature.planSpec;
@@ -162,6 +164,20 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
const currentTaskId = planSpec.currentTaskId;
return planSpec.tasks.map((task: ParsedTask, index: number) => {
// When feature is finished (waiting_approval/verified), finalize task display:
// - in_progress tasks → completed (agent was working on them when it finished)
// - pending tasks stay pending (they were never started)
// - completed tasks stay completed
// This matches server-side behavior in feature-state-manager.ts
if (isFeatureFinished) {
const finalStatus =
task.status === 'in_progress' || task.status === 'failed' ? 'completed' : task.status;
return {
content: task.description,
status: (finalStatus || 'completed') as 'pending' | 'in_progress' | 'completed',
};
}
// Use real-time status from WebSocket events if available
const realtimeStatus = taskStatusMap.get(task.id);
@@ -198,6 +214,7 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
feature.planSpec?.currentTaskId,
agentInfo?.todos,
taskStatusMap,
isFeatureFinished,
]);
// Listen to WebSocket events for real-time task status updates

View File

@@ -293,56 +293,59 @@ export const CardActions = memo(function CardActions({
) : null}
</>
)}
{!isCurrentAutoTask && feature.status === 'backlog' && (
<>
<Button
variant="secondary"
size="sm"
className="flex-1 h-7 text-xs"
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`edit-backlog-${feature.id}`}
>
<Edit className="w-3 h-3 mr-1" />
Edit
</Button>
{feature.planSpec?.content && onViewPlan && (
{!isCurrentAutoTask &&
(feature.status === 'backlog' ||
feature.status === 'interrupted' ||
feature.status === 'ready') && (
<>
<Button
variant="outline"
size="sm"
className="h-7 text-xs px-2"
onClick={(e) => {
e.stopPropagation();
onViewPlan();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`view-plan-${feature.id}`}
title="View Plan"
>
<Eye className="w-3 h-3" />
</Button>
)}
{onImplement && (
<Button
variant="default"
variant="secondary"
size="sm"
className="flex-1 h-7 text-xs"
onClick={(e) => {
e.stopPropagation();
onImplement();
onEdit();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`make-${feature.id}`}
data-testid={`edit-backlog-${feature.id}`}
>
<PlayCircle className="w-3 h-3 mr-1" />
Make
<Edit className="w-3 h-3 mr-1" />
Edit
</Button>
)}
</>
)}
{feature.planSpec?.content && onViewPlan && (
<Button
variant="outline"
size="sm"
className="h-7 text-xs px-2"
onClick={(e) => {
e.stopPropagation();
onViewPlan();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`view-plan-${feature.id}`}
title="View Plan"
>
<Eye className="w-3 h-3" />
</Button>
)}
{onImplement && (
<Button
variant="default"
size="sm"
className="flex-1 h-7 text-xs"
onClick={(e) => {
e.stopPropagation();
onImplement();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`make-${feature.id}`}
>
<PlayCircle className="w-3 h-3 mr-1" />
Make
</Button>
)}
</>
)}
</div>
);
});

View File

@@ -1,5 +1,5 @@
// @ts-nocheck - header component props with optional handlers and status variants
import { memo, useState } from 'react';
import type { DraggableAttributes, DraggableSyntheticListeners } from '@dnd-kit/core';
import { Feature } from '@/store/app-store';
import { cn } from '@/lib/utils';
import { CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@@ -8,6 +8,9 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
@@ -19,6 +22,7 @@ import {
ChevronDown,
ChevronUp,
GitFork,
Copy,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { CountUpTimer } from '@/components/ui/count-up-timer';
@@ -26,6 +30,65 @@ import { formatModelName, DEFAULT_MODEL } from '@/lib/agent-context-parser';
import { DeleteConfirmDialog } from '@/components/ui/delete-confirm-dialog';
import { getProviderIconForModel } from '@/components/ui/provider-icon';
function DuplicateMenuItems({
onDuplicate,
onDuplicateAsChild,
}: {
onDuplicate?: () => void;
onDuplicateAsChild?: () => void;
}) {
if (!onDuplicate) return null;
// When there's no sub-child action, render a simple menu item (no DropdownMenuSub wrapper)
if (!onDuplicateAsChild) {
return (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDuplicate();
}}
className="text-xs"
>
<Copy className="w-3 h-3 mr-2" />
Duplicate
</DropdownMenuItem>
);
}
// When sub-child action is available, render a proper DropdownMenuSub with
// DropdownMenuSubTrigger and DropdownMenuSubContent per Radix conventions
return (
<DropdownMenuSub>
<DropdownMenuSubTrigger className="text-xs">
<Copy className="w-3 h-3 mr-2" />
Duplicate
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDuplicate();
}}
className="text-xs"
>
<Copy className="w-3 h-3 mr-2" />
Duplicate
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDuplicateAsChild();
}}
className="text-xs"
>
<GitFork className="w-3 h-3 mr-2" />
Duplicate as Child
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
);
}
interface CardHeaderProps {
feature: Feature;
isDraggable: boolean;
@@ -35,6 +98,10 @@ interface CardHeaderProps {
onDelete: () => void;
onViewOutput?: () => void;
onSpawnTask?: () => void;
onDuplicate?: () => void;
onDuplicateAsChild?: () => void;
dragHandleListeners?: DraggableSyntheticListeners;
dragHandleAttributes?: DraggableAttributes;
}
export const CardHeaderSection = memo(function CardHeaderSection({
@@ -46,6 +113,10 @@ export const CardHeaderSection = memo(function CardHeaderSection({
onDelete,
onViewOutput,
onSpawnTask,
onDuplicate,
onDuplicateAsChild,
dragHandleListeners,
dragHandleAttributes,
}: CardHeaderProps) {
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
@@ -66,7 +137,7 @@ export const CardHeaderSection = memo(function CardHeaderSection({
<div className="absolute top-2 right-2 flex items-center gap-1">
<div className="flex items-center justify-center gap-2 bg-[var(--status-in-progress)]/15 border border-[var(--status-in-progress)]/50 rounded-md px-2 py-0.5">
<Spinner size="xs" />
{feature.startedAt && (
{typeof feature.startedAt === 'string' && (
<CountUpTimer
startedAt={feature.startedAt}
className="text-[var(--status-in-progress)] text-[10px]"
@@ -109,6 +180,10 @@ export const CardHeaderSection = memo(function CardHeaderSection({
<GitFork className="w-3 h-3 mr-2" />
Spawn Sub-Task
</DropdownMenuItem>
<DuplicateMenuItems
onDuplicate={onDuplicate}
onDuplicateAsChild={onDuplicateAsChild}
/>
{/* Model info in dropdown */}
{(() => {
const ProviderIcon = getProviderIconForModel(feature.model);
@@ -126,35 +201,62 @@ export const CardHeaderSection = memo(function CardHeaderSection({
</div>
)}
{/* Backlog header */}
{!isCurrentAutoTask && !isSelectionMode && feature.status === 'backlog' && (
<div className="absolute top-2 right-2 flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation();
onSpawnTask?.();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`spawn-backlog-${feature.id}`}
title="Spawn Sub-Task"
>
<GitFork className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-destructive"
onClick={handleDeleteClick}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`delete-backlog-${feature.id}`}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
)}
{/* Backlog header (also handles 'interrupted' and 'ready' statuses that display in backlog column) */}
{!isCurrentAutoTask &&
!isSelectionMode &&
(feature.status === 'backlog' ||
feature.status === 'interrupted' ||
feature.status === 'ready') && (
<div className="absolute top-2 right-2 flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation();
onSpawnTask?.();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`spawn-backlog-${feature.id}`}
title="Spawn Sub-Task"
>
<GitFork className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-destructive"
onClick={handleDeleteClick}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`delete-backlog-${feature.id}`}
>
<Trash2 className="w-4 h-4" />
</Button>
{/* Only render overflow menu when there are actionable items */}
{onDuplicate && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-muted/80 rounded-md"
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`menu-backlog-${feature.id}`}
>
<MoreVertical className="w-3.5 h-3.5 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DuplicateMenuItems
onDuplicate={onDuplicate}
onDuplicateAsChild={onDuplicateAsChild}
/>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
)}
{/* Waiting approval / Verified header */}
{!isCurrentAutoTask &&
@@ -178,22 +280,6 @@ export const CardHeaderSection = memo(function CardHeaderSection({
>
<Edit className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation();
onSpawnTask?.();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`spawn-${
feature.status === 'waiting_approval' ? 'waiting' : 'verified'
}-${feature.id}`}
title="Spawn Sub-Task"
>
<GitFork className="w-4 h-4" />
</Button>
{onViewOutput && (
<Button
variant="ghost"
@@ -225,6 +311,41 @@ export const CardHeaderSection = memo(function CardHeaderSection({
>
<Trash2 className="w-4 h-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-muted/80 rounded-md"
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`menu-${
feature.status === 'waiting_approval' ? 'waiting' : 'verified'
}-${feature.id}`}
>
<MoreVertical className="w-3.5 h-3.5 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onSpawnTask?.();
}}
data-testid={`spawn-${
feature.status === 'waiting_approval' ? 'waiting' : 'verified'
}-${feature.id}`}
className="text-xs"
>
<GitFork className="w-3 h-3 mr-2" />
Spawn Sub-Task
</DropdownMenuItem>
<DuplicateMenuItems
onDuplicate={onDuplicate}
onDuplicateAsChild={onDuplicateAsChild}
/>
</DropdownMenuContent>
</DropdownMenu>
</div>
</>
)}
@@ -293,6 +414,10 @@ export const CardHeaderSection = memo(function CardHeaderSection({
<GitFork className="w-3 h-3 mr-2" />
Spawn Sub-Task
</DropdownMenuItem>
<DuplicateMenuItems
onDuplicate={onDuplicate}
onDuplicateAsChild={onDuplicateAsChild}
/>
{/* Model info in dropdown */}
{(() => {
const ProviderIcon = getProviderIconForModel(feature.model);
@@ -315,8 +440,10 @@ export const CardHeaderSection = memo(function CardHeaderSection({
<div className="flex items-start gap-2">
{isDraggable && (
<div
className="-ml-2 -mt-1 p-2 touch-none opacity-40 hover:opacity-70 transition-opacity"
className="-ml-2 -mt-1 p-2 touch-none cursor-grab active:cursor-grabbing opacity-40 hover:opacity-70 transition-opacity"
data-testid={`drag-handle-${feature.id}`}
{...dragHandleAttributes}
{...dragHandleListeners}
>
<GripVertical className="w-3.5 h-3.5 text-muted-foreground" />
</div>

View File

@@ -32,7 +32,7 @@ function getCursorClass(
): string {
if (isSelectionMode) return 'cursor-pointer';
if (isOverlay) return 'cursor-grabbing';
if (isDraggable) return 'cursor-grab active:cursor-grabbing';
// Drag cursor is now only on the drag handle, not the full card
return 'cursor-default';
}
@@ -52,6 +52,8 @@ interface KanbanCardProps {
onViewPlan?: () => void;
onApprovePlan?: () => void;
onSpawnTask?: () => void;
onDuplicate?: () => void;
onDuplicateAsChild?: () => void;
hasContext?: boolean;
isCurrentAutoTask?: boolean;
shortcutKey?: string;
@@ -86,6 +88,8 @@ export const KanbanCard = memo(function KanbanCard({
onViewPlan,
onApprovePlan,
onSpawnTask,
onDuplicate,
onDuplicateAsChild,
hasContext,
isCurrentAutoTask,
shortcutKey,
@@ -108,6 +112,9 @@ export const KanbanCard = memo(function KanbanCard({
currentProject: state.currentProject,
}))
);
// A card in waiting_approval should not display as "actively running" even if
// it's still in the runningAutoTasks list. The waiting_approval UI takes precedence.
const isActivelyRunning = !!isCurrentAutoTask && feature.status !== 'waiting_approval';
const [isLifted, setIsLifted] = useState(false);
useLayoutEffect(() => {
@@ -121,6 +128,8 @@ export const KanbanCard = memo(function KanbanCard({
const isDraggable =
!isSelectionMode &&
(feature.status === 'backlog' ||
feature.status === 'interrupted' ||
feature.status === 'ready' ||
feature.status === 'waiting_approval' ||
feature.status === 'verified' ||
feature.status.startsWith('pipeline_') ||
@@ -167,7 +176,7 @@ export const KanbanCard = memo(function KanbanCard({
const isSelectable = isSelectionMode && feature.status === selectionTarget;
const wrapperClasses = cn(
'relative select-none outline-none touch-none transition-transform duration-200 ease-out',
'relative select-none outline-none transition-transform duration-200 ease-out',
getCursorClass(isOverlay, isDraggable, isSelectable),
isOverlay && isLifted && 'scale-105 rotate-1 z-50',
// Visual feedback when another card is being dragged over this one
@@ -184,10 +193,10 @@ export const KanbanCard = memo(function KanbanCard({
// Disable hover translate for in-progress cards to prevent gap showing gradient
isInteractive &&
!reduceEffects &&
!isCurrentAutoTask &&
!isActivelyRunning &&
'hover:-translate-y-0.5 hover:shadow-md hover:shadow-black/10 bg-transparent',
!glassmorphism && 'backdrop-blur-[0px]!',
!isCurrentAutoTask &&
!isActivelyRunning &&
cardBorderEnabled &&
(cardBorderOpacity === 100 ? 'border-border/50' : 'border'),
hasError && 'border-[var(--status-error)] border-2 shadow-[var(--status-error-bg)] shadow-lg',
@@ -204,7 +213,7 @@ export const KanbanCard = memo(function KanbanCard({
const renderCardContent = () => (
<Card
style={isCurrentAutoTask ? undefined : cardStyle}
style={isActivelyRunning ? undefined : cardStyle}
className={innerCardClasses}
onDoubleClick={isSelectionMode ? undefined : onEdit}
onClick={handleCardClick}
@@ -243,12 +252,16 @@ export const KanbanCard = memo(function KanbanCard({
<CardHeaderSection
feature={feature}
isDraggable={isDraggable}
isCurrentAutoTask={!!isCurrentAutoTask}
isCurrentAutoTask={isActivelyRunning}
isSelectionMode={isSelectionMode}
onEdit={onEdit}
onDelete={onDelete}
onViewOutput={onViewOutput}
onSpawnTask={onSpawnTask}
onDuplicate={onDuplicate}
onDuplicateAsChild={onDuplicateAsChild}
dragHandleListeners={isDraggable ? listeners : undefined}
dragHandleAttributes={isDraggable ? attributes : undefined}
/>
<CardContent className="px-3 pt-0 pb-0">
@@ -267,7 +280,7 @@ export const KanbanCard = memo(function KanbanCard({
{/* Actions */}
<CardActions
feature={feature}
isCurrentAutoTask={!!isCurrentAutoTask}
isCurrentAutoTask={isActivelyRunning}
hasContext={hasContext}
shortcutKey={shortcutKey}
isSelectionMode={isSelectionMode}
@@ -291,12 +304,10 @@ export const KanbanCard = memo(function KanbanCard({
<div
ref={setNodeRef}
style={dndStyle}
{...attributes}
{...(isDraggable ? listeners : {})}
className={wrapperClasses}
data-testid={`kanban-card-${feature.id}`}
>
{isCurrentAutoTask ? (
{isActivelyRunning ? (
<div className="animated-border-wrapper">{renderCardContent()}</div>
) : (
renderCardContent()

View File

@@ -209,6 +209,10 @@ export const ListRow = memo(function ListRow({
blockingDependencies = [],
className,
}: ListRowProps) {
// A card in waiting_approval should not display as "actively running" even if
// it's still in the runningAutoTasks list. The waiting_approval UI takes precedence.
const isActivelyRunning = isCurrentAutoTask && feature.status !== 'waiting_approval';
const handleRowClick = useCallback(
(e: React.MouseEvent) => {
// Don't trigger row click if clicking on checkbox or actions
@@ -349,13 +353,13 @@ export const ListRow = memo(function ListRow({
{/* Actions column */}
<div role="cell" className="flex items-center justify-end px-3 py-3 w-[80px] shrink-0">
<RowActions feature={feature} handlers={handlers} isCurrentAutoTask={isCurrentAutoTask} />
<RowActions feature={feature} handlers={handlers} isCurrentAutoTask={isActivelyRunning} />
</div>
</div>
);
// Wrap with animated border for currently running auto task
if (isCurrentAutoTask) {
if (isActivelyRunning) {
return <div className="animated-border-wrapper-row">{rowContent}</div>;
}

View File

@@ -42,6 +42,8 @@ export interface ListViewActionHandlers {
onViewPlan?: (feature: Feature) => void;
onApprovePlan?: (feature: Feature) => void;
onSpawnTask?: (feature: Feature) => void;
onDuplicate?: (feature: Feature) => void;
onDuplicateAsChild?: (feature: Feature) => void;
}
export interface ListViewProps {
@@ -313,6 +315,18 @@ export const ListView = memo(function ListView({
if (f) actionHandlers.onSpawnTask?.(f);
}
: undefined,
duplicate: actionHandlers.onDuplicate
? (id) => {
const f = allFeatures.find((f) => f.id === id);
if (f) actionHandlers.onDuplicate?.(f);
}
: undefined,
duplicateAsChild: actionHandlers.onDuplicateAsChild
? (id) => {
const f = allFeatures.find((f) => f.id === id);
if (f) actionHandlers.onDuplicateAsChild?.(f);
}
: undefined,
});
},
[actionHandlers, allFeatures]

View File

@@ -14,6 +14,7 @@ import {
GitBranch,
GitFork,
ExternalLink,
Copy,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
@@ -22,6 +23,9 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import type { Feature } from '@/store/app-store';
@@ -43,6 +47,8 @@ export interface RowActionHandlers {
onViewPlan?: () => void;
onApprovePlan?: () => void;
onSpawnTask?: () => void;
onDuplicate?: () => void;
onDuplicateAsChild?: () => void;
}
export interface RowActionsProps {
@@ -405,6 +411,31 @@ export const RowActions = memo(function RowActions({
onClick={withClose(handlers.onSpawnTask)}
/>
)}
{handlers.onDuplicate && (
<DropdownMenuSub>
<div className="flex items-center">
<DropdownMenuItem
onClick={withClose(handlers.onDuplicate)}
className="flex-1 pr-0 rounded-r-none"
>
<Copy className="w-4 h-4 mr-2" />
Duplicate
</DropdownMenuItem>
{handlers.onDuplicateAsChild && (
<DropdownMenuSubTrigger className="px-1 rounded-l-none border-l border-border/30 h-8" />
)}
</div>
{handlers.onDuplicateAsChild && (
<DropdownMenuSubContent>
<MenuItem
icon={GitFork}
label="Duplicate as Child"
onClick={withClose(handlers.onDuplicateAsChild)}
/>
</DropdownMenuSubContent>
)}
</DropdownMenuSub>
)}
<DropdownMenuSeparator />
<MenuItem
icon={Trash2}
@@ -457,6 +488,31 @@ export const RowActions = memo(function RowActions({
onClick={withClose(handlers.onSpawnTask)}
/>
)}
{handlers.onDuplicate && (
<DropdownMenuSub>
<div className="flex items-center">
<DropdownMenuItem
onClick={withClose(handlers.onDuplicate)}
className="flex-1 pr-0 rounded-r-none"
>
<Copy className="w-4 h-4 mr-2" />
Duplicate
</DropdownMenuItem>
{handlers.onDuplicateAsChild && (
<DropdownMenuSubTrigger className="px-1 rounded-l-none border-l border-border/30 h-8" />
)}
</div>
{handlers.onDuplicateAsChild && (
<DropdownMenuSubContent>
<MenuItem
icon={GitFork}
label="Duplicate as Child"
onClick={withClose(handlers.onDuplicateAsChild)}
/>
</DropdownMenuSubContent>
)}
</DropdownMenuSub>
)}
<MenuItem
icon={Trash2}
label="Delete"
@@ -503,6 +559,31 @@ export const RowActions = memo(function RowActions({
onClick={withClose(handlers.onSpawnTask)}
/>
)}
{handlers.onDuplicate && (
<DropdownMenuSub>
<div className="flex items-center">
<DropdownMenuItem
onClick={withClose(handlers.onDuplicate)}
className="flex-1 pr-0 rounded-r-none"
>
<Copy className="w-4 h-4 mr-2" />
Duplicate
</DropdownMenuItem>
{handlers.onDuplicateAsChild && (
<DropdownMenuSubTrigger className="px-1 rounded-l-none border-l border-border/30 h-8" />
)}
</div>
{handlers.onDuplicateAsChild && (
<DropdownMenuSubContent>
<MenuItem
icon={GitFork}
label="Duplicate as Child"
onClick={withClose(handlers.onDuplicateAsChild)}
/>
</DropdownMenuSubContent>
)}
</DropdownMenuSub>
)}
<MenuItem
icon={Trash2}
label="Delete"
@@ -554,6 +635,31 @@ export const RowActions = memo(function RowActions({
onClick={withClose(handlers.onSpawnTask)}
/>
)}
{handlers.onDuplicate && (
<DropdownMenuSub>
<div className="flex items-center">
<DropdownMenuItem
onClick={withClose(handlers.onDuplicate)}
className="flex-1 pr-0 rounded-r-none"
>
<Copy className="w-4 h-4 mr-2" />
Duplicate
</DropdownMenuItem>
{handlers.onDuplicateAsChild && (
<DropdownMenuSubTrigger className="px-1 rounded-l-none border-l border-border/30 h-8" />
)}
</div>
{handlers.onDuplicateAsChild && (
<DropdownMenuSubContent>
<MenuItem
icon={GitFork}
label="Duplicate as Child"
onClick={withClose(handlers.onDuplicateAsChild)}
/>
</DropdownMenuSubContent>
)}
</DropdownMenuSub>
)}
<MenuItem
icon={Trash2}
label="Delete"
@@ -581,6 +687,31 @@ export const RowActions = memo(function RowActions({
onClick={withClose(handlers.onSpawnTask)}
/>
)}
{handlers.onDuplicate && (
<DropdownMenuSub>
<div className="flex items-center">
<DropdownMenuItem
onClick={withClose(handlers.onDuplicate)}
className="flex-1 pr-0 rounded-r-none"
>
<Copy className="w-4 h-4 mr-2" />
Duplicate
</DropdownMenuItem>
{handlers.onDuplicateAsChild && (
<DropdownMenuSubTrigger className="px-1 rounded-l-none border-l border-border/30 h-8" />
)}
</div>
{handlers.onDuplicateAsChild && (
<DropdownMenuSubContent>
<MenuItem
icon={GitFork}
label="Duplicate as Child"
onClick={withClose(handlers.onDuplicateAsChild)}
/>
</DropdownMenuSubContent>
)}
</DropdownMenuSub>
)}
<DropdownMenuSeparator />
<MenuItem
icon={Trash2}
@@ -615,6 +746,8 @@ export function createRowActionHandlers(
viewPlan?: (id: string) => void;
approvePlan?: (id: string) => void;
spawnTask?: (id: string) => void;
duplicate?: (id: string) => void;
duplicateAsChild?: (id: string) => void;
}
): RowActionHandlers {
return {
@@ -631,5 +764,9 @@ export function createRowActionHandlers(
onViewPlan: actions.viewPlan ? () => actions.viewPlan!(featureId) : undefined,
onApprovePlan: actions.approvePlan ? () => actions.approvePlan!(featureId) : undefined,
onSpawnTask: actions.spawnTask ? () => actions.spawnTask!(featureId) : undefined,
onDuplicate: actions.duplicate ? () => actions.duplicate!(featureId) : undefined,
onDuplicateAsChild: actions.duplicateAsChild
? () => actions.duplicateAsChild!(featureId)
: undefined,
};
}

View File

@@ -28,7 +28,7 @@ import { cn } from '@/lib/utils';
import { modelSupportsThinking } from '@/lib/utils';
import { useAppStore, ThinkingLevel, FeatureImage, PlanningMode, Feature } from '@/store/app-store';
import type { ReasoningEffort, PhaseModelEntry, AgentModel } from '@automaker/types';
import { supportsReasoningEffort } from '@automaker/types';
import { supportsReasoningEffort, isAdaptiveThinkingModel } from '@automaker/types';
import {
PrioritySelector,
WorkModeSelector,
@@ -264,7 +264,20 @@ export function AddFeatureDialog({
}, [planningMode]);
const handleModelChange = (entry: PhaseModelEntry) => {
setModelEntry(entry);
// Normalize thinking level when switching between adaptive and non-adaptive models
const isNewModelAdaptive =
typeof entry.model === 'string' && isAdaptiveThinkingModel(entry.model);
const currentLevel = entry.thinkingLevel || 'none';
if (isNewModelAdaptive && currentLevel !== 'none' && currentLevel !== 'adaptive') {
// Switching TO Opus 4.6 with a manual level -> auto-switch to 'adaptive'
setModelEntry({ ...entry, thinkingLevel: 'adaptive' });
} else if (!isNewModelAdaptive && currentLevel === 'adaptive') {
// Switching FROM Opus 4.6 with adaptive -> auto-switch to 'high'
setModelEntry({ ...entry, thinkingLevel: 'high' });
} else {
setModelEntry(entry);
}
};
const buildFeatureData = (): FeatureData | null => {

View File

@@ -241,9 +241,9 @@ export function CreatePRDialog({
<GitPullRequest className="w-5 h-5" />
Create Pull Request
</DialogTitle>
<DialogDescription>
<DialogDescription className="break-words">
Push changes and create a pull request from{' '}
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>
<code className="font-mono bg-muted px-1 rounded break-all">{worktree.branch}</code>
</DialogDescription>
</DialogHeader>

View File

@@ -225,7 +225,13 @@ export function useBoardActions({
};
const createdFeature = addFeature(newFeatureData);
// Must await to ensure feature exists on server before user can drag it
await persistFeatureCreate(createdFeature);
try {
await persistFeatureCreate(createdFeature);
} catch (error) {
// Remove the feature from state if server creation failed (e.g., duplicate title)
removeFeature(createdFeature.id);
throw error;
}
saveCategory(featureData.category);
// Handle child dependencies - update other features to depend on this new feature
@@ -276,6 +282,7 @@ export function useBoardActions({
},
[
addFeature,
removeFeature,
persistFeatureCreate,
persistFeatureUpdate,
updateFeature,
@@ -510,7 +517,7 @@ export function useBoardActions({
}
removeFeature(featureId);
persistFeatureDelete(featureId);
await persistFeatureDelete(featureId);
},
[features, runningAutoTasks, autoMode, removeFeature, persistFeatureDelete]
);
@@ -1083,6 +1090,38 @@ export function useBoardActions({
});
}, [features, runningAutoTasks, autoMode, updateFeature, persistFeatureUpdate]);
const handleDuplicateFeature = useCallback(
async (feature: Feature, asChild: boolean = false) => {
// Copy all feature data, stripping id, status (handled by create), and runtime/state fields
const {
id: _id,
status: _status,
startedAt: _startedAt,
error: _error,
summary: _summary,
spec: _spec,
passes: _passes,
planSpec: _planSpec,
descriptionHistory: _descriptionHistory,
titleGenerating: _titleGenerating,
...featureData
} = feature;
const duplicatedFeatureData = {
...featureData,
// If duplicating as child, set source as dependency; otherwise keep existing
...(asChild && { dependencies: [feature.id] }),
};
// Reuse the existing handleAddFeature logic
await handleAddFeature(duplicatedFeatureData);
toast.success(asChild ? 'Duplicated as child' : 'Feature duplicated', {
description: `Created copy of: ${truncateDescription(feature.description || feature.title || '')}`,
});
},
[handleAddFeature]
);
return {
handleAddFeature,
handleUpdateFeature,
@@ -1103,5 +1142,6 @@ export function useBoardActions({
handleForceStopFeature,
handleStartNextFeatures,
handleArchiveAllVerified,
handleDuplicateFeature,
};
}

View File

@@ -3,6 +3,7 @@ import { createLogger } from '@automaker/utils/logger';
import { DragStartEvent, DragEndEvent } from '@dnd-kit/core';
import { Feature } from '@/store/app-store';
import { useAppStore } from '@/store/app-store';
import { useAutoMode } from '@/hooks/use-auto-mode';
import { toast } from 'sonner';
import { COLUMNS, ColumnId } from '../constants';
@@ -33,6 +34,7 @@ export function useBoardDragDrop({
null
);
const { moveFeature, updateFeature } = useAppStore();
const autoMode = useAutoMode();
// Note: getOrCreateWorktreeForFeature removed - worktrees are now created server-side
// at execution time based on feature.branchName
@@ -155,19 +157,9 @@ export function useBoardDragDrop({
}
}
// Determine if dragging is allowed based on status and skipTests
// - Backlog items can always be dragged
// - waiting_approval items can always be dragged (to allow manual verification via drag)
// - verified items can always be dragged (to allow moving back to waiting_approval)
// - in_progress items can be dragged (but not if they're currently running)
// - Non-skipTests (TDD) items that are in progress cannot be dragged if they are running
if (draggedFeature.status === 'in_progress') {
// Only allow dragging in_progress if it's not currently running
if (isRunningTask) {
logger.debug('Cannot drag feature - currently running');
return;
}
}
// Determine if dragging is allowed based on status
// Running in_progress features CAN be dragged to backlog (stops the agent)
// but cannot be dragged to other columns
let targetStatus: ColumnId | null = null;
@@ -235,15 +227,38 @@ export function useBoardDragDrop({
} else if (draggedFeature.status === 'in_progress') {
// Handle in_progress features being moved
if (targetStatus === 'backlog') {
// Allow moving in_progress cards back to backlog
// If the feature is currently running, stop it first
if (isRunningTask) {
try {
await autoMode.stopFeature(featureId);
logger.info('Stopped running feature via drag to backlog:', featureId);
} catch (error) {
logger.error('Error stopping feature during drag to backlog:', error);
toast.error('Failed to stop agent', {
description: 'The feature will still be moved to backlog.',
});
}
}
moveFeature(featureId, 'backlog');
persistFeatureUpdate(featureId, { status: 'backlog' });
toast.info('Feature moved to backlog', {
description: `Moved to Backlog: ${draggedFeature.description.slice(
0,
50
)}${draggedFeature.description.length > 50 ? '...' : ''}`,
toast.info(
isRunningTask
? 'Agent stopped and feature moved to backlog'
: 'Feature moved to backlog',
{
description: `Moved to Backlog: ${draggedFeature.description.slice(
0,
50
)}${draggedFeature.description.length > 50 ? '...' : ''}`,
}
);
} else if (isRunningTask) {
// Running features can only be dragged to backlog, not other columns
logger.debug('Cannot drag running feature to', targetStatus);
toast.error('Cannot move running feature', {
description: 'Stop the agent first or drag to Backlog to stop and move.',
});
return;
} else if (targetStatus === 'verified' && draggedFeature.skipTests) {
// Manual verify via drag (only for skipTests features)
moveFeature(featureId, 'verified');
@@ -310,6 +325,7 @@ export function useBoardDragDrop({
updateFeature,
persistFeatureUpdate,
handleStartImplementation,
autoMode,
]
);

View File

@@ -75,27 +75,58 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
);
// Persist feature creation to API
// Throws on failure so callers can handle it (e.g., remove the feature from state)
const persistFeatureCreate = useCallback(
async (feature: Feature) => {
if (!currentProject) return;
try {
const api = getElectronAPI();
if (!api.features) {
logger.error('Features API not available');
return;
}
const api = getElectronAPI();
if (!api.features) {
throw new Error('Features API not available');
}
// Capture previous cache snapshot for synchronous rollback on error
const previousFeatures = queryClient.getQueryData<Feature[]>(
queryKeys.features.all(currentProject.path)
);
// Optimistically add to React Query cache for immediate board refresh
queryClient.setQueryData<Feature[]>(
queryKeys.features.all(currentProject.path),
(existing) => (existing ? [...existing, feature] : [feature])
);
try {
const result = await api.features.create(currentProject.path, feature as ApiFeature);
if (result.success && result.feature) {
updateFeature(result.feature.id, result.feature as Partial<Feature>);
// Invalidate React Query cache to sync UI
queryClient.invalidateQueries({
queryKey: queryKeys.features.all(currentProject.path),
});
// Update cache with server-confirmed feature before invalidating
queryClient.setQueryData<Feature[]>(
queryKeys.features.all(currentProject.path),
(features) => {
if (!features) return features;
return features.map((f) =>
f.id === result.feature!.id ? { ...f, ...(result.feature as Feature) } : f
);
}
);
} else if (!result.success) {
throw new Error(result.error || 'Failed to create feature on server');
}
// Always invalidate to sync with server state
queryClient.invalidateQueries({
queryKey: queryKeys.features.all(currentProject.path),
});
} catch (error) {
logger.error('Failed to persist feature creation:', error);
// Rollback optimistic update synchronously on error
if (previousFeatures) {
queryClient.setQueryData(queryKeys.features.all(currentProject.path), previousFeatures);
}
queryClient.invalidateQueries({
queryKey: queryKeys.features.all(currentProject.path),
});
throw error;
}
},
[currentProject, updateFeature, queryClient]
@@ -106,20 +137,42 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
async (featureId: string) => {
if (!currentProject) return;
// Optimistically remove from React Query cache for immediate board refresh
const previousFeatures = queryClient.getQueryData<Feature[]>(
queryKeys.features.all(currentProject.path)
);
queryClient.setQueryData<Feature[]>(
queryKeys.features.all(currentProject.path),
(existing) => (existing ? existing.filter((f) => f.id !== featureId) : existing)
);
try {
const api = getElectronAPI();
if (!api.features) {
logger.error('Features API not available');
return;
// Rollback optimistic deletion since we can't persist
if (previousFeatures) {
queryClient.setQueryData(queryKeys.features.all(currentProject.path), previousFeatures);
}
queryClient.invalidateQueries({
queryKey: queryKeys.features.all(currentProject.path),
});
throw new Error('Features API not available');
}
await api.features.delete(currentProject.path, featureId);
// Invalidate React Query cache to sync UI
// Invalidate to sync with server state
queryClient.invalidateQueries({
queryKey: queryKeys.features.all(currentProject.path),
});
} catch (error) {
logger.error('Failed to persist feature deletion:', error);
// Rollback optimistic update on error
if (previousFeatures) {
queryClient.setQueryData(queryKeys.features.all(currentProject.path), previousFeatures);
}
queryClient.invalidateQueries({
queryKey: queryKeys.features.all(currentProject.path),
});
}
},
[currentProject, queryClient]

View File

@@ -46,6 +46,8 @@ interface KanbanBoardProps {
onViewPlan: (feature: Feature) => void;
onApprovePlan: (feature: Feature) => void;
onSpawnTask?: (feature: Feature) => void;
onDuplicate?: (feature: Feature) => void;
onDuplicateAsChild?: (feature: Feature) => void;
featuresWithContext: Set<string>;
runningAutoTasks: string[];
onArchiveAllVerified: () => void;
@@ -282,6 +284,8 @@ export function KanbanBoard({
onViewPlan,
onApprovePlan,
onSpawnTask,
onDuplicate,
onDuplicateAsChild,
featuresWithContext,
runningAutoTasks,
onArchiveAllVerified,
@@ -569,6 +573,8 @@ export function KanbanBoard({
onViewPlan={() => onViewPlan(feature)}
onApprovePlan={() => onApprovePlan(feature)}
onSpawnTask={() => onSpawnTask?.(feature)}
onDuplicate={() => onDuplicate?.(feature)}
onDuplicateAsChild={() => onDuplicateAsChild?.(feature)}
hasContext={featuresWithContext.has(feature.id)}
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
shortcutKey={shortcutKey}
@@ -611,6 +617,8 @@ export function KanbanBoard({
onViewPlan={() => onViewPlan(feature)}
onApprovePlan={() => onApprovePlan(feature)}
onSpawnTask={() => onSpawnTask?.(feature)}
onDuplicate={() => onDuplicate?.(feature)}
onDuplicateAsChild={() => onDuplicateAsChild?.(feature)}
hasContext={featuresWithContext.has(feature.id)}
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
shortcutKey={shortcutKey}

View File

@@ -5,6 +5,7 @@ import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import { AnthropicIcon, OpenAIIcon } from '@/components/ui/provider-icon';
import { getExpectedWeeklyPacePercentage, getPaceStatusLabel } from '@/store/utils/usage-utils';
interface MobileUsageBarProps {
showClaudeUsage: boolean;
@@ -23,11 +24,15 @@ function UsageBar({
label,
percentage,
isStale,
pacePercentage,
}: {
label: string;
percentage: number;
isStale: boolean;
pacePercentage?: number | null;
}) {
const paceLabel = pacePercentage != null ? getPaceStatusLabel(percentage, pacePercentage) : null;
return (
<div className="mt-1.5 first:mt-0">
<div className="flex items-center justify-between mb-0.5">
@@ -49,7 +54,7 @@ function UsageBar({
</div>
<div
className={cn(
'h-1 w-full bg-muted-foreground/10 rounded-full overflow-hidden transition-opacity',
'relative h-1 w-full bg-muted-foreground/10 rounded-full overflow-hidden transition-opacity',
isStale && 'opacity-60'
)}
>
@@ -57,7 +62,24 @@ function UsageBar({
className={cn('h-full transition-all duration-500', getProgressBarColor(percentage))}
style={{ width: `${Math.min(percentage, 100)}%` }}
/>
{pacePercentage != null && pacePercentage > 0 && pacePercentage < 100 && (
<div
className="absolute top-0 h-full w-0.5 bg-foreground/60"
style={{ left: `${pacePercentage}%` }}
title={`Expected: ${Math.round(pacePercentage)}%`}
/>
)}
</div>
{paceLabel && (
<p
className={cn(
'text-[9px] mt-0.5',
percentage > (pacePercentage ?? 0) ? 'text-orange-500' : 'text-green-500'
)}
>
{paceLabel}
</p>
)}
</div>
);
}
@@ -190,6 +212,7 @@ export function MobileUsageBar({ showClaudeUsage, showCodexUsage }: MobileUsageB
label="Weekly"
percentage={claudeUsage.weeklyPercentage}
isStale={isClaudeStale}
pacePercentage={getExpectedWeeklyPacePercentage(claudeUsage.weeklyResetTime)}
/>
</>
) : (

View File

@@ -167,7 +167,14 @@ export const ALL_MODELS: ModelOption[] = [
...COPILOT_MODELS,
];
export const THINKING_LEVELS: ThinkingLevel[] = ['none', 'low', 'medium', 'high', 'ultrathink'];
export const THINKING_LEVELS: ThinkingLevel[] = [
'none',
'low',
'medium',
'high',
'ultrathink',
'adaptive',
];
export const THINKING_LEVEL_LABELS: Record<ThinkingLevel, string> = {
none: 'None',
@@ -175,6 +182,7 @@ export const THINKING_LEVEL_LABELS: Record<ThinkingLevel, string> = {
medium: 'Med',
high: 'High',
ultrathink: 'Ultra',
adaptive: 'Adaptive',
};
/**

View File

@@ -2,19 +2,26 @@ import { Label } from '@/components/ui/label';
import { Brain } from 'lucide-react';
import { cn } from '@/lib/utils';
import { ThinkingLevel } from '@/store/app-store';
import { THINKING_LEVELS, THINKING_LEVEL_LABELS } from './model-constants';
import { THINKING_LEVEL_LABELS } from './model-constants';
import { getThinkingLevelsForModel } from '@automaker/types';
interface ThinkingLevelSelectorProps {
selectedLevel: ThinkingLevel;
onLevelSelect: (level: ThinkingLevel) => void;
testIdPrefix?: string;
/** Model ID is required for correct thinking level filtering.
* Without it, adaptive thinking won't be available for Opus 4.6. */
model?: string;
}
export function ThinkingLevelSelector({
selectedLevel,
onLevelSelect,
testIdPrefix = 'thinking-level',
model,
}: ThinkingLevelSelectorProps) {
const levels = getThinkingLevelsForModel(model || '');
return (
<div className="space-y-2 pt-2 border-t border-border">
<Label className="flex items-center gap-2 text-sm">
@@ -22,7 +29,7 @@ export function ThinkingLevelSelector({
Thinking Level
</Label>
<div className="flex gap-2 flex-wrap">
{THINKING_LEVELS.map((level) => (
{levels.map((level) => (
<button
key={level}
type="button"
@@ -40,7 +47,9 @@ export function ThinkingLevelSelector({
))}
</div>
<p className="text-xs text-muted-foreground">
Higher levels give more time to reason through complex problems.
{levels.includes('adaptive')
? 'Adaptive thinking lets the model decide how much reasoning to use.'
: 'Higher levels give more time to reason through complex problems.'}
</p>
</div>
);

View File

@@ -112,6 +112,8 @@ export function WorktreePanel({
// Use separate selectors to avoid creating new object references on each render
const autoModeByWorktree = useAppStore((state) => state.autoModeByWorktree);
const currentProject = useAppStore((state) => state.currentProject);
const setAutoModeRunning = useAppStore((state) => state.setAutoModeRunning);
const getMaxConcurrencyForWorktree = useAppStore((state) => state.getMaxConcurrencyForWorktree);
// Helper to generate worktree key for auto-mode (inlined to avoid selector issues)
const getAutoModeWorktreeKey = useCallback(
@@ -137,8 +139,6 @@ export function WorktreePanel({
async (worktree: WorktreeInfo) => {
if (!currentProject) return;
// Import the useAutoMode to get start/stop functions
// Since useAutoMode is a hook, we'll use the API client directly
const api = getHttpApiClient();
const branchName = worktree.isMain ? null : worktree.branch;
const isRunning = isAutoModeRunningForWorktree(worktree);
@@ -147,14 +147,17 @@ export function WorktreePanel({
if (isRunning) {
const result = await api.autoMode.stop(projectPath, branchName);
if (result.success) {
setAutoModeRunning(currentProject.id, branchName, false);
const desc = branchName ? `worktree ${branchName}` : 'main branch';
toast.success(`Auto Mode stopped for ${desc}`);
} else {
toast.error(result.error || 'Failed to stop Auto Mode');
}
} else {
const result = await api.autoMode.start(projectPath, branchName);
const maxConcurrency = getMaxConcurrencyForWorktree(currentProject.id, branchName);
const result = await api.autoMode.start(projectPath, branchName, maxConcurrency);
if (result.success) {
setAutoModeRunning(currentProject.id, branchName, true, maxConcurrency);
const desc = branchName ? `worktree ${branchName}` : 'main branch';
toast.success(`Auto Mode started for ${desc}`);
} else {
@@ -166,7 +169,13 @@ export function WorktreePanel({
console.error('Auto mode toggle error:', error);
}
},
[currentProject, projectPath, isAutoModeRunningForWorktree]
[
currentProject,
projectPath,
isAutoModeRunningForWorktree,
setAutoModeRunning,
getMaxConcurrencyForWorktree,
]
);
// Check if init script exists for the project using React Query

View File

@@ -313,14 +313,21 @@ export function GraphViewPage() {
// Handle add and start feature
const handleAddAndStartFeature = useCallback(
async (featureData: Parameters<typeof handleAddFeature>[0]) => {
const featuresBeforeIds = new Set(useAppStore.getState().features.map((f) => f.id));
await handleAddFeature(featureData);
try {
const featuresBeforeIds = new Set(useAppStore.getState().features.map((f) => f.id));
await handleAddFeature(featureData);
const latestFeatures = useAppStore.getState().features;
const newFeature = latestFeatures.find((f) => !featuresBeforeIds.has(f.id));
const latestFeatures = useAppStore.getState().features;
const newFeature = latestFeatures.find((f) => !featuresBeforeIds.has(f.id));
if (newFeature) {
await handleStartImplementation(newFeature);
if (newFeature) {
await handleStartImplementation(newFeature);
}
} catch (error) {
logger.error('Failed to add and start feature:', error);
toast.error(
`Failed to add and start feature: ${error instanceof Error ? error.message : String(error)}`
);
}
},
[handleAddFeature, handleStartImplementation]

View File

@@ -21,6 +21,7 @@ import {
isGroupSelected,
getSelectedVariant,
codexModelHasThinking,
getThinkingLevelsForModel,
} from '@automaker/types';
import {
CLAUDE_MODELS,
@@ -28,7 +29,6 @@ import {
OPENCODE_MODELS,
GEMINI_MODELS,
COPILOT_MODELS,
THINKING_LEVELS,
THINKING_LEVEL_LABELS,
REASONING_EFFORT_LEVELS,
REASONING_EFFORT_LABELS,
@@ -1296,7 +1296,9 @@ export function PhaseModelSelector({
<div className="px-2 py-1 text-xs font-medium text-muted-foreground">
Thinking Level
</div>
{THINKING_LEVELS.map((level) => (
{getThinkingLevelsForModel(
model.mapsToClaudeModel === 'opus' ? 'claude-opus' : model.id || ''
).map((level) => (
<button
key={level}
onClick={() => {
@@ -1322,6 +1324,7 @@ export function PhaseModelSelector({
{level === 'medium' && 'Moderate reasoning (10k tokens)'}
{level === 'high' && 'Deep reasoning (16k tokens)'}
{level === 'ultrathink' && 'Maximum reasoning (32k tokens)'}
{level === 'adaptive' && 'Model decides reasoning depth'}
</span>
</div>
{isSelected && currentThinking === level && (
@@ -1402,7 +1405,9 @@ export function PhaseModelSelector({
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground border-b border-border/50 mb-1">
Thinking Level
</div>
{THINKING_LEVELS.map((level) => (
{getThinkingLevelsForModel(
model.mapsToClaudeModel === 'opus' ? 'claude-opus' : model.id || ''
).map((level) => (
<button
key={level}
onClick={() => {
@@ -1428,6 +1433,7 @@ export function PhaseModelSelector({
{level === 'medium' && 'Moderate reasoning (10k tokens)'}
{level === 'high' && 'Deep reasoning (16k tokens)'}
{level === 'ultrathink' && 'Maximum reasoning (32k tokens)'}
{level === 'adaptive' && 'Model decides reasoning depth'}
</span>
</div>
{isSelected && currentThinking === level && (
@@ -1564,7 +1570,7 @@ export function PhaseModelSelector({
<div className="px-2 py-1 text-xs font-medium text-muted-foreground">
Thinking Level
</div>
{THINKING_LEVELS.map((level) => (
{getThinkingLevelsForModel(model.id).map((level) => (
<button
key={level}
onClick={() => {
@@ -1589,6 +1595,7 @@ export function PhaseModelSelector({
{level === 'medium' && 'Moderate reasoning (10k tokens)'}
{level === 'high' && 'Deep reasoning (16k tokens)'}
{level === 'ultrathink' && 'Maximum reasoning (32k tokens)'}
{level === 'adaptive' && 'Model decides reasoning depth'}
</span>
</div>
{isSelected && currentThinking === level && (
@@ -1685,7 +1692,7 @@ export function PhaseModelSelector({
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground border-b border-border/50 mb-1">
Thinking Level
</div>
{THINKING_LEVELS.map((level) => (
{getThinkingLevelsForModel(model.id).map((level) => (
<button
key={level}
onClick={() => {
@@ -1710,6 +1717,7 @@ export function PhaseModelSelector({
{level === 'medium' && 'Moderate reasoning (10k tokens)'}
{level === 'high' && 'Deep reasoning (16k tokens)'}
{level === 'ultrathink' && 'Maximum reasoning (32k tokens)'}
{level === 'adaptive' && 'Model decides reasoning depth'}
</span>
</div>
{isSelected && currentThinking === level && (

View File

@@ -9,7 +9,7 @@ import {
SelectValue,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import type { CodexModelId } from '@automaker/types';
import { supportsReasoningEffort, type CodexModelId } from '@automaker/types';
import { OpenAIIcon } from '@/components/ui/provider-icon';
interface CodexModelConfigurationProps {
@@ -27,25 +27,30 @@ interface CodexModelInfo {
}
const CODEX_MODEL_INFO: Record<CodexModelId, CodexModelInfo> = {
'codex-gpt-5.3-codex': {
id: 'codex-gpt-5.3-codex',
label: 'GPT-5.3-Codex',
description: 'Latest frontier agentic coding model',
},
'codex-gpt-5.2-codex': {
id: 'codex-gpt-5.2-codex',
label: 'GPT-5.2-Codex',
description: 'Most advanced agentic coding model for complex software engineering',
description: 'Frontier agentic coding model',
},
'codex-gpt-5.1-codex-max': {
id: 'codex-gpt-5.1-codex-max',
label: 'GPT-5.1-Codex-Max',
description: 'Optimized for long-horizon, agentic coding tasks in Codex',
description: 'Codex-optimized flagship for deep and fast reasoning',
},
'codex-gpt-5.1-codex-mini': {
id: 'codex-gpt-5.1-codex-mini',
label: 'GPT-5.1-Codex-Mini',
description: 'Smaller, more cost-effective version for faster workflows',
description: 'Optimized for codex. Cheaper, faster, but less capable',
},
'codex-gpt-5.2': {
id: 'codex-gpt-5.2',
label: 'GPT-5.2',
description: 'Best general agentic model for tasks across industries and domains',
description: 'Latest frontier model with improvements across knowledge, reasoning and coding',
},
'codex-gpt-5.1': {
id: 'codex-gpt-5.1',
@@ -157,13 +162,3 @@ export function CodexModelConfiguration({
</div>
);
}
function supportsReasoningEffort(modelId: string): boolean {
const reasoningModels = [
'codex-gpt-5.2-codex',
'codex-gpt-5.1-codex-max',
'codex-gpt-5.2',
'codex-gpt-5.1',
];
return reasoningModels.includes(modelId);
}

View File

@@ -0,0 +1,283 @@
/**
* Prompt Preview - Shows a live preview of the custom terminal prompt
*/
import type { ReactNode } from 'react';
import { cn } from '@/lib/utils';
import type { ThemeMode } from '@automaker/types';
import { getTerminalTheme } from '@/config/terminal-themes';
interface PromptPreviewProps {
format: 'standard' | 'minimal' | 'powerline' | 'starship';
theme: ThemeMode;
showGitBranch: boolean;
showGitStatus: boolean;
showUserHost: boolean;
showPath: boolean;
pathStyle: 'full' | 'short' | 'basename';
pathDepth: number;
showTime: boolean;
showExitStatus: boolean;
isOmpTheme?: boolean;
promptThemeLabel?: string;
className?: string;
}
export function PromptPreview({
format,
theme,
showGitBranch,
showGitStatus,
showUserHost,
showPath,
pathStyle,
pathDepth,
showTime,
showExitStatus,
isOmpTheme = false,
promptThemeLabel,
className,
}: PromptPreviewProps) {
const terminalTheme = getTerminalTheme(theme);
const formatPath = (inputPath: string) => {
let displayPath = inputPath;
let prefix = '';
if (displayPath.startsWith('~/')) {
prefix = '~/';
displayPath = displayPath.slice(2);
} else if (displayPath.startsWith('/')) {
prefix = '/';
displayPath = displayPath.slice(1);
}
const segments = displayPath.split('/').filter((segment) => segment.length > 0);
const depth = Math.max(0, pathDepth);
const trimmedSegments = depth > 0 ? segments.slice(-depth) : segments;
let formattedSegments = trimmedSegments;
if (pathStyle === 'basename' && trimmedSegments.length > 0) {
formattedSegments = [trimmedSegments[trimmedSegments.length - 1]];
} else if (pathStyle === 'short') {
formattedSegments = trimmedSegments.map((segment, index) => {
if (index < trimmedSegments.length - 1) {
return segment.slice(0, 1);
}
return segment;
});
}
const joined = formattedSegments.join('/');
if (prefix === '/' && joined.length === 0) {
return '/';
}
if (prefix === '~/' && joined.length === 0) {
return '~';
}
return `${prefix}${joined}`;
};
// Generate preview text based on format
const renderPrompt = () => {
if (isOmpTheme) {
return (
<div className="font-mono text-sm leading-relaxed space-y-2">
<div style={{ color: terminalTheme.magenta }}>
{promptThemeLabel ?? 'Oh My Posh theme'}
</div>
<div className="text-xs text-muted-foreground">
Rendered by the oh-my-posh CLI in the terminal.
</div>
<div className="text-xs text-muted-foreground">
Preview here stays generic to avoid misleading output.
</div>
</div>
);
}
const user = 'user';
const host = 'automaker';
const path = formatPath('~/projects/automaker');
const branch = showGitBranch ? 'main' : null;
const dirty = showGitStatus && showGitBranch ? '*' : '';
const time = showTime ? '[14:32]' : '';
const status = showExitStatus ? '✗ 1' : '';
const gitInfo = branch ? ` (${branch}${dirty})` : '';
switch (format) {
case 'minimal': {
return (
<div className="font-mono text-sm leading-relaxed">
{showTime && <span style={{ color: terminalTheme.magenta }}>{time} </span>}
{showUserHost && (
<span style={{ color: terminalTheme.cyan }}>
{user}
<span style={{ color: terminalTheme.foreground }}>@</span>
<span style={{ color: terminalTheme.blue }}>{host}</span>{' '}
</span>
)}
{showPath && <span style={{ color: terminalTheme.yellow }}>{path}</span>}
{gitInfo && <span style={{ color: terminalTheme.magenta }}>{gitInfo}</span>}
{showExitStatus && <span style={{ color: terminalTheme.red }}> {status}</span>}
<span style={{ color: terminalTheme.green }}> $</span>
<span className="ml-1 animate-pulse"></span>
</div>
);
}
case 'powerline': {
const powerlineSegments: ReactNode[] = [];
if (showUserHost) {
powerlineSegments.push(
<span key="user-host" style={{ color: terminalTheme.cyan }}>
[{user}
<span style={{ color: terminalTheme.foreground }}>@</span>
<span style={{ color: terminalTheme.blue }}>{host}</span>]
</span>
);
}
if (showPath) {
powerlineSegments.push(
<span key="path" style={{ color: terminalTheme.yellow }}>
[{path}]
</span>
);
}
const powerlineCore = powerlineSegments.flatMap((segment, index) =>
index === 0
? [segment]
: [
<span key={`sep-${index}`} style={{ color: terminalTheme.cyan }}>
</span>,
segment,
]
);
const powerlineExtras: ReactNode[] = [];
if (gitInfo) {
powerlineExtras.push(
<span key="git" style={{ color: terminalTheme.magenta }}>
{gitInfo}
</span>
);
}
if (showTime) {
powerlineExtras.push(
<span key="time" style={{ color: terminalTheme.magenta }}>
{time}
</span>
);
}
if (showExitStatus) {
powerlineExtras.push(
<span key="status" style={{ color: terminalTheme.red }}>
{status}
</span>
);
}
const powerlineLine: ReactNode[] = [...powerlineCore];
if (powerlineExtras.length > 0) {
if (powerlineLine.length > 0) {
powerlineLine.push(' ');
}
powerlineLine.push(...powerlineExtras);
}
return (
<div className="font-mono text-sm leading-relaxed space-y-1">
<div>
<span style={{ color: terminalTheme.cyan }}></span>
{powerlineLine}
</div>
<div>
<span style={{ color: terminalTheme.cyan }}></span>
<span style={{ color: terminalTheme.green }}>$</span>
<span className="ml-1 animate-pulse"></span>
</div>
</div>
);
}
case 'starship': {
return (
<div className="font-mono text-sm leading-relaxed space-y-1">
<div>
{showTime && <span style={{ color: terminalTheme.magenta }}>{time} </span>}
{showUserHost && (
<>
<span style={{ color: terminalTheme.cyan }}>{user}</span>
<span style={{ color: terminalTheme.foreground }}>@</span>
<span style={{ color: terminalTheme.blue }}>{host}</span>
</>
)}
{showPath && (
<>
<span style={{ color: terminalTheme.foreground }}> in </span>
<span style={{ color: terminalTheme.yellow }}>{path}</span>
</>
)}
{branch && (
<>
<span style={{ color: terminalTheme.foreground }}> on </span>
<span style={{ color: terminalTheme.magenta }}>
{branch}
{dirty}
</span>
</>
)}
{showExitStatus && <span style={{ color: terminalTheme.red }}> {status}</span>}
</div>
<div>
<span style={{ color: terminalTheme.green }}></span>
<span className="ml-1 animate-pulse"></span>
</div>
</div>
);
}
case 'standard':
default: {
return (
<div className="font-mono text-sm leading-relaxed">
{showTime && <span style={{ color: terminalTheme.magenta }}>{time} </span>}
{showUserHost && (
<>
<span style={{ color: terminalTheme.cyan }}>[{user}</span>
<span style={{ color: terminalTheme.foreground }}>@</span>
<span style={{ color: terminalTheme.blue }}>{host}</span>
<span style={{ color: terminalTheme.cyan }}>]</span>
</>
)}
{showPath && <span style={{ color: terminalTheme.yellow }}> {path}</span>}
{gitInfo && <span style={{ color: terminalTheme.magenta }}>{gitInfo}</span>}
{showExitStatus && <span style={{ color: terminalTheme.red }}> {status}</span>}
<span style={{ color: terminalTheme.green }}> $</span>
<span className="ml-1 animate-pulse"></span>
</div>
);
}
}
};
return (
<div
className={cn(
'rounded-lg border p-4',
'bg-[var(--terminal-bg)] text-[var(--terminal-fg)]',
'shadow-inner',
className
)}
style={
{
'--terminal-bg': terminalTheme.background,
'--terminal-fg': terminalTheme.foreground,
} as React.CSSProperties
}
>
<div className="mb-2 text-xs text-muted-foreground opacity-70">Preview</div>
{renderPrompt()}
</div>
);
}

View File

@@ -0,0 +1,253 @@
import type { TerminalPromptTheme } from '@automaker/types';
export const PROMPT_THEME_CUSTOM_ID: TerminalPromptTheme = 'custom';
export const OMP_THEME_NAMES = [
'1_shell',
'M365Princess',
'agnoster',
'agnoster.minimal',
'agnosterplus',
'aliens',
'amro',
'atomic',
'atomicBit',
'avit',
'blue-owl',
'blueish',
'bubbles',
'bubblesextra',
'bubblesline',
'capr4n',
'catppuccin',
'catppuccin_frappe',
'catppuccin_latte',
'catppuccin_macchiato',
'catppuccin_mocha',
'cert',
'chips',
'cinnamon',
'clean-detailed',
'cloud-context',
'cloud-native-azure',
'cobalt2',
'craver',
'darkblood',
'devious-diamonds',
'di4am0nd',
'dracula',
'easy-term',
'emodipt',
'emodipt-extend',
'fish',
'free-ukraine',
'froczh',
'gmay',
'glowsticks',
'grandpa-style',
'gruvbox',
'half-life',
'honukai',
'hotstick.minimal',
'hul10',
'hunk',
'huvix',
'if_tea',
'illusi0n',
'iterm2',
'jandedobbeleer',
'jblab_2021',
'jonnychipz',
'json',
'jtracey93',
'jv_sitecorian',
'kali',
'kushal',
'lambda',
'lambdageneration',
'larserikfinholt',
'lightgreen',
'marcduiker',
'markbull',
'material',
'microverse-power',
'mojada',
'montys',
'mt',
'multiverse-neon',
'negligible',
'neko',
'night-owl',
'nordtron',
'nu4a',
'onehalf.minimal',
'paradox',
'pararussel',
'patriksvensson',
'peru',
'pixelrobots',
'plague',
'poshmon',
'powerlevel10k_classic',
'powerlevel10k_lean',
'powerlevel10k_modern',
'powerlevel10k_rainbow',
'powerline',
'probua.minimal',
'pure',
'quick-term',
'remk',
'robbyrussell',
'rudolfs-dark',
'rudolfs-light',
'sim-web',
'slim',
'slimfat',
'smoothie',
'sonicboom_dark',
'sonicboom_light',
'sorin',
'space',
'spaceship',
'star',
'stelbent-compact.minimal',
'stelbent.minimal',
'takuya',
'the-unnamed',
'thecyberden',
'tiwahu',
'tokyo',
'tokyonight_storm',
'tonybaloney',
'uew',
'unicorn',
'velvet',
'wholespace',
'wopian',
'xtoys',
'ys',
'zash',
] as const;
type OmpThemeName = (typeof OMP_THEME_NAMES)[number];
type PromptFormat = 'standard' | 'minimal' | 'powerline' | 'starship';
type PathStyle = 'full' | 'short' | 'basename';
export interface PromptThemeConfig {
promptFormat: PromptFormat;
showGitBranch: boolean;
showGitStatus: boolean;
showUserHost: boolean;
showPath: boolean;
pathStyle: PathStyle;
pathDepth: number;
showTime: boolean;
showExitStatus: boolean;
}
export interface PromptThemePreset {
id: TerminalPromptTheme;
label: string;
description: string;
config: PromptThemeConfig;
}
const PATH_DEPTH_FULL = 0;
const PATH_DEPTH_TWO = 2;
const PATH_DEPTH_THREE = 3;
const POWERLINE_HINTS = ['powerline', 'powerlevel10k', 'agnoster', 'bubbles', 'smoothie'];
const MINIMAL_HINTS = ['minimal', 'pure', 'slim', 'negligible'];
const STARSHIP_HINTS = ['spaceship', 'star'];
const SHORT_PATH_HINTS = ['compact', 'lean', 'slim'];
const TIME_HINTS = ['time', 'clock'];
const EXIT_STATUS_HINTS = ['status', 'exit', 'fail', 'error'];
function toPromptThemeId(name: OmpThemeName): TerminalPromptTheme {
return `omp-${name}` as TerminalPromptTheme;
}
function formatLabel(name: string): string {
const cleaned = name.replace(/[._-]+/g, ' ').trim();
return cleaned
.split(' ')
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}
function buildPresetConfig(name: OmpThemeName): PromptThemeConfig {
const lower = name.toLowerCase();
const isPowerline = POWERLINE_HINTS.some((hint) => lower.includes(hint));
const isMinimal = MINIMAL_HINTS.some((hint) => lower.includes(hint));
const isStarship = STARSHIP_HINTS.some((hint) => lower.includes(hint));
let promptFormat: PromptFormat = 'standard';
if (isPowerline) {
promptFormat = 'powerline';
} else if (isMinimal) {
promptFormat = 'minimal';
} else if (isStarship) {
promptFormat = 'starship';
}
const showUserHost = !isMinimal;
const showPath = true;
const pathStyle: PathStyle = isMinimal ? 'short' : 'full';
let pathDepth = isMinimal ? PATH_DEPTH_THREE : PATH_DEPTH_FULL;
if (SHORT_PATH_HINTS.some((hint) => lower.includes(hint))) {
pathDepth = PATH_DEPTH_TWO;
}
if (lower.includes('powerlevel10k')) {
pathDepth = PATH_DEPTH_THREE;
}
const showTime = TIME_HINTS.some((hint) => lower.includes(hint));
const showExitStatus = EXIT_STATUS_HINTS.some((hint) => lower.includes(hint));
return {
promptFormat,
showGitBranch: true,
showGitStatus: true,
showUserHost,
showPath,
pathStyle,
pathDepth,
showTime,
showExitStatus,
};
}
export const PROMPT_THEME_PRESETS: PromptThemePreset[] = OMP_THEME_NAMES.map((name) => ({
id: toPromptThemeId(name),
label: `${formatLabel(name)} (OMP)`,
description: 'Oh My Posh theme preset',
config: buildPresetConfig(name),
}));
export function getPromptThemePreset(presetId: TerminalPromptTheme): PromptThemePreset | null {
return PROMPT_THEME_PRESETS.find((preset) => preset.id === presetId) ?? null;
}
export function getMatchingPromptThemeId(config: PromptThemeConfig): TerminalPromptTheme {
const match = PROMPT_THEME_PRESETS.find((preset) => {
const presetConfig = preset.config;
return (
presetConfig.promptFormat === config.promptFormat &&
presetConfig.showGitBranch === config.showGitBranch &&
presetConfig.showGitStatus === config.showGitStatus &&
presetConfig.showUserHost === config.showUserHost &&
presetConfig.showPath === config.showPath &&
presetConfig.pathStyle === config.pathStyle &&
presetConfig.pathDepth === config.pathDepth &&
presetConfig.showTime === config.showTime &&
presetConfig.showExitStatus === config.showExitStatus
);
});
return match?.id ?? PROMPT_THEME_CUSTOM_ID;
}

View File

@@ -0,0 +1,662 @@
/**
* Terminal Config Section - Custom terminal configurations with theme synchronization
*
* This component provides UI for enabling custom terminal prompts that automatically
* sync with Automaker's 40 themes. It's an opt-in feature that generates shell configs
* in .automaker/terminal/ without modifying user's existing RC files.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Wand2, GitBranch, Info, Plus, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAppStore } from '@/store/app-store';
import { toast } from 'sonner';
import { PromptPreview } from './prompt-preview';
import type { TerminalPromptTheme } from '@automaker/types';
import {
PROMPT_THEME_CUSTOM_ID,
PROMPT_THEME_PRESETS,
getMatchingPromptThemeId,
getPromptThemePreset,
type PromptThemeConfig,
} from './prompt-theme-presets';
import { useUpdateGlobalSettings } from '@/hooks/mutations/use-settings-mutations';
import { useGlobalSettings } from '@/hooks/queries/use-settings';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
export function TerminalConfigSection() {
const PATH_DEPTH_MIN = 0;
const PATH_DEPTH_MAX = 10;
const ENV_VAR_UPDATE_DEBOUNCE_MS = 400;
const ENV_VAR_ID_PREFIX = 'env';
const TERMINAL_RC_FILE_VERSION = 11;
const { theme } = useAppStore();
const { data: globalSettings } = useGlobalSettings();
const updateGlobalSettings = useUpdateGlobalSettings({ showSuccessToast: false });
const envVarIdRef = useRef(0);
const envVarUpdateTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const createEnvVarEntry = useCallback(
(key = '', value = '') => {
envVarIdRef.current += 1;
return {
id: `${ENV_VAR_ID_PREFIX}-${envVarIdRef.current}`,
key,
value,
};
},
[ENV_VAR_ID_PREFIX]
);
const [localEnvVars, setLocalEnvVars] = useState<
Array<{ id: string; key: string; value: string }>
>(() =>
Object.entries(globalSettings?.terminalConfig?.customEnvVars || {}).map(([key, value]) =>
createEnvVarEntry(key, value)
)
);
const [showEnableConfirm, setShowEnableConfirm] = useState(false);
const clampPathDepth = (value: number) =>
Math.min(PATH_DEPTH_MAX, Math.max(PATH_DEPTH_MIN, value));
const defaultTerminalConfig = {
enabled: false,
customPrompt: true,
promptFormat: 'standard' as const,
promptTheme: PROMPT_THEME_CUSTOM_ID,
showGitBranch: true,
showGitStatus: true,
showUserHost: true,
showPath: true,
pathStyle: 'full' as const,
pathDepth: PATH_DEPTH_MIN,
showTime: false,
showExitStatus: false,
customAliases: '',
customEnvVars: {},
};
const terminalConfig = {
...defaultTerminalConfig,
...globalSettings?.terminalConfig,
customAliases:
globalSettings?.terminalConfig?.customAliases ?? defaultTerminalConfig.customAliases,
customEnvVars:
globalSettings?.terminalConfig?.customEnvVars ?? defaultTerminalConfig.customEnvVars,
};
const promptThemeConfig: PromptThemeConfig = {
promptFormat: terminalConfig.promptFormat,
showGitBranch: terminalConfig.showGitBranch,
showGitStatus: terminalConfig.showGitStatus,
showUserHost: terminalConfig.showUserHost,
showPath: terminalConfig.showPath,
pathStyle: terminalConfig.pathStyle,
pathDepth: terminalConfig.pathDepth,
showTime: terminalConfig.showTime,
showExitStatus: terminalConfig.showExitStatus,
};
const storedPromptTheme = terminalConfig.promptTheme;
const activePromptThemeId =
storedPromptTheme === PROMPT_THEME_CUSTOM_ID
? PROMPT_THEME_CUSTOM_ID
: (storedPromptTheme ?? getMatchingPromptThemeId(promptThemeConfig));
const isOmpTheme =
storedPromptTheme !== undefined && storedPromptTheme !== PROMPT_THEME_CUSTOM_ID;
const promptThemePreset = isOmpTheme
? getPromptThemePreset(storedPromptTheme as TerminalPromptTheme)
: null;
const applyEnabledUpdate = (enabled: boolean) => {
// Ensure all required fields are present
const updatedConfig = {
enabled,
customPrompt: terminalConfig.customPrompt,
promptFormat: terminalConfig.promptFormat,
showGitBranch: terminalConfig.showGitBranch,
showGitStatus: terminalConfig.showGitStatus,
showUserHost: terminalConfig.showUserHost,
showPath: terminalConfig.showPath,
pathStyle: terminalConfig.pathStyle,
pathDepth: terminalConfig.pathDepth,
showTime: terminalConfig.showTime,
showExitStatus: terminalConfig.showExitStatus,
promptTheme: terminalConfig.promptTheme ?? PROMPT_THEME_CUSTOM_ID,
customAliases: terminalConfig.customAliases,
customEnvVars: terminalConfig.customEnvVars,
rcFileVersion: TERMINAL_RC_FILE_VERSION,
};
updateGlobalSettings.mutate(
{ terminalConfig: updatedConfig },
{
onSuccess: () => {
toast.success(
enabled ? 'Custom terminal configs enabled' : 'Custom terminal configs disabled',
{
description: enabled
? 'New terminals will use custom prompts'
: '.automaker/terminal/ will be cleaned up',
}
);
},
onError: (error) => {
console.error('[TerminalConfig] Failed to update settings:', error);
toast.error('Failed to update terminal config', {
description: error instanceof Error ? error.message : 'Unknown error',
});
},
}
);
};
useEffect(() => {
setLocalEnvVars(
Object.entries(globalSettings?.terminalConfig?.customEnvVars || {}).map(([key, value]) =>
createEnvVarEntry(key, value)
)
);
}, [createEnvVarEntry, globalSettings?.terminalConfig?.customEnvVars]);
useEffect(() => {
return () => {
if (envVarUpdateTimeoutRef.current) {
clearTimeout(envVarUpdateTimeoutRef.current);
}
};
}, []);
const handleToggleEnabled = async (enabled: boolean) => {
if (enabled) {
setShowEnableConfirm(true);
return;
}
applyEnabledUpdate(false);
};
const handleUpdateConfig = (updates: Partial<typeof terminalConfig>) => {
const nextPromptTheme = updates.promptTheme ?? PROMPT_THEME_CUSTOM_ID;
updateGlobalSettings.mutate(
{
terminalConfig: {
...terminalConfig,
...updates,
promptTheme: nextPromptTheme,
},
},
{
onError: (error) => {
console.error('[TerminalConfig] Failed to update settings:', error);
toast.error('Failed to update terminal config', {
description: error instanceof Error ? error.message : 'Unknown error',
});
},
}
);
};
const scheduleEnvVarsUpdate = (envVarsObject: Record<string, string>) => {
if (envVarUpdateTimeoutRef.current) {
clearTimeout(envVarUpdateTimeoutRef.current);
}
envVarUpdateTimeoutRef.current = setTimeout(() => {
handleUpdateConfig({ customEnvVars: envVarsObject });
}, ENV_VAR_UPDATE_DEBOUNCE_MS);
};
const handlePromptThemeChange = (themeId: string) => {
if (themeId === PROMPT_THEME_CUSTOM_ID) {
handleUpdateConfig({ promptTheme: PROMPT_THEME_CUSTOM_ID });
return;
}
const preset = getPromptThemePreset(themeId as TerminalPromptTheme);
if (!preset) {
handleUpdateConfig({ promptTheme: PROMPT_THEME_CUSTOM_ID });
return;
}
handleUpdateConfig({
...preset.config,
promptTheme: preset.id,
});
};
const addEnvVar = () => {
setLocalEnvVars([...localEnvVars, createEnvVarEntry()]);
};
const removeEnvVar = (id: string) => {
const newVars = localEnvVars.filter((envVar) => envVar.id !== id);
setLocalEnvVars(newVars);
// Update settings
const envVarsObject = newVars.reduce(
(acc, { key, value }) => {
if (key) acc[key] = value;
return acc;
},
{} as Record<string, string>
);
scheduleEnvVarsUpdate(envVarsObject);
};
const updateEnvVar = (id: string, field: 'key' | 'value', newValue: string) => {
const newVars = localEnvVars.map((envVar) =>
envVar.id === id ? { ...envVar, [field]: newValue } : envVar
);
setLocalEnvVars(newVars);
// Validate and update settings (only if key is valid)
const envVarsObject = newVars.reduce(
(acc, { key, value }) => {
// Only include vars with valid keys (alphanumeric + underscore)
if (key && /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {
acc[key] = value;
}
return acc;
},
{} as Record<string, string>
);
scheduleEnvVarsUpdate(envVarsObject);
};
return (
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-purple-500/5 to-transparent">
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-purple-500/20 to-purple-600/10 flex items-center justify-center border border-purple-500/20">
<Wand2 className="w-5 h-5 text-purple-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">
Custom Terminal Configurations
</h2>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Generate custom shell prompts that automatically sync with your app theme. Opt-in feature
that creates configs in .automaker/terminal/ without modifying your existing RC files.
</p>
</div>
<div className="p-6 space-y-6">
{/* Enable Toggle */}
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label className="text-foreground font-medium">Enable Custom Configurations</Label>
<p className="text-xs text-muted-foreground">
Create theme-synced shell configs in .automaker/terminal/
</p>
</div>
<Switch checked={terminalConfig.enabled} onCheckedChange={handleToggleEnabled} />
</div>
{terminalConfig.enabled && (
<>
{/* Info Box */}
<div className="rounded-lg border border-purple-500/20 bg-purple-500/5 p-3 flex gap-2">
<Info className="h-4 w-4 text-purple-500 flex-shrink-0 mt-0.5" />
<div className="text-xs text-foreground/80">
<strong>How it works:</strong> Custom configs are applied to new terminals only.
Your ~/.bashrc and ~/.zshrc are still loaded first. Close and reopen terminals to
see changes.
</div>
</div>
{/* Custom Prompt Toggle */}
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label className="text-foreground font-medium">Custom Prompt</Label>
<p className="text-xs text-muted-foreground">
Override default shell prompt with themed version
</p>
</div>
<Switch
checked={terminalConfig.customPrompt}
onCheckedChange={(checked) => handleUpdateConfig({ customPrompt: checked })}
/>
</div>
{terminalConfig.customPrompt && (
<>
{/* Prompt Format */}
<div className="space-y-3">
<Label className="text-foreground font-medium">Prompt Theme (Oh My Posh)</Label>
<Select value={activePromptThemeId} onValueChange={handlePromptThemeChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={PROMPT_THEME_CUSTOM_ID}>
<div className="space-y-0.5">
<div>Custom</div>
<div className="text-xs text-muted-foreground">
Hand-tuned configuration
</div>
</div>
</SelectItem>
{PROMPT_THEME_PRESETS.map((preset) => (
<SelectItem key={preset.id} value={preset.id}>
<div className="space-y-0.5">
<div>{preset.label}</div>
<div className="text-xs text-muted-foreground">
{preset.description}
</div>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{isOmpTheme && (
<div className="rounded-lg border border-emerald-500/20 bg-emerald-500/5 p-3 flex gap-2">
<Info className="h-4 w-4 text-emerald-500 flex-shrink-0 mt-0.5" />
<div className="text-xs text-foreground/80">
<strong>{promptThemePreset?.label ?? 'Oh My Posh theme'}</strong> uses the
oh-my-posh CLI for rendering. Ensure it&apos;s installed for the full theme.
Prompt format and segment toggles are ignored while an OMP theme is selected.
</div>
</div>
)}
<div className="space-y-3">
<Label className="text-foreground font-medium">Prompt Format</Label>
<Select
value={terminalConfig.promptFormat}
onValueChange={(value: 'standard' | 'minimal' | 'powerline' | 'starship') =>
handleUpdateConfig({ promptFormat: value })
}
disabled={isOmpTheme}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="standard">
<div className="space-y-0.5">
<div>Standard</div>
<div className="text-xs text-muted-foreground">
[user@host] ~/path (main*) $
</div>
</div>
</SelectItem>
<SelectItem value="minimal">
<div className="space-y-0.5">
<div>Minimal</div>
<div className="text-xs text-muted-foreground">~/path (main*) $</div>
</div>
</SelectItem>
<SelectItem value="powerline">
<div className="space-y-0.5">
<div>Powerline</div>
<div className="text-xs text-muted-foreground">
[user@host][~/path][main*]
</div>
</div>
</SelectItem>
<SelectItem value="starship">
<div className="space-y-0.5">
<div>Starship-Inspired</div>
<div className="text-xs text-muted-foreground">
user@host in ~/path on main*
</div>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
{/* Git Info Toggles */}
<div className="space-y-4 pl-4 border-l-2 border-border/30">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<GitBranch className="w-4 h-4 text-muted-foreground" />
<Label className="text-sm">Show Git Branch</Label>
</div>
<Switch
checked={terminalConfig.showGitBranch}
onCheckedChange={(checked) => handleUpdateConfig({ showGitBranch: checked })}
disabled={isOmpTheme}
/>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">*</span>
<Label className="text-sm">Show Git Status (dirty indicator)</Label>
</div>
<Switch
checked={terminalConfig.showGitStatus}
onCheckedChange={(checked) => handleUpdateConfig({ showGitStatus: checked })}
disabled={!terminalConfig.showGitBranch || isOmpTheme}
/>
</div>
</div>
{/* Prompt Segments */}
<div className="space-y-4 pl-4 border-l-2 border-border/30">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Wand2 className="w-4 h-4 text-muted-foreground" />
<Label className="text-sm">Show User & Host</Label>
</div>
<Switch
checked={terminalConfig.showUserHost}
onCheckedChange={(checked) => handleUpdateConfig({ showUserHost: checked })}
disabled={isOmpTheme}
/>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">~/</span>
<Label className="text-sm">Show Path</Label>
</div>
<Switch
checked={terminalConfig.showPath}
onCheckedChange={(checked) => handleUpdateConfig({ showPath: checked })}
disabled={isOmpTheme}
/>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground"></span>
<Label className="text-sm">Show Time</Label>
</div>
<Switch
checked={terminalConfig.showTime}
onCheckedChange={(checked) => handleUpdateConfig({ showTime: checked })}
disabled={isOmpTheme}
/>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground"></span>
<Label className="text-sm">Show Exit Status</Label>
</div>
<Switch
checked={terminalConfig.showExitStatus}
onCheckedChange={(checked) => handleUpdateConfig({ showExitStatus: checked })}
disabled={isOmpTheme}
/>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">Path Style</Label>
<Select
value={terminalConfig.pathStyle}
onValueChange={(value: 'full' | 'short' | 'basename') =>
handleUpdateConfig({ pathStyle: value })
}
disabled={!terminalConfig.showPath || isOmpTheme}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="full">Full</SelectItem>
<SelectItem value="short">Short</SelectItem>
<SelectItem value="basename">Basename</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">Path Depth</Label>
<Input
type="number"
min={PATH_DEPTH_MIN}
max={PATH_DEPTH_MAX}
value={terminalConfig.pathDepth}
onChange={(event) =>
handleUpdateConfig({
pathDepth: clampPathDepth(Number(event.target.value) || 0),
})
}
disabled={!terminalConfig.showPath || isOmpTheme}
/>
</div>
</div>
</div>
{/* Live Preview */}
<div className="space-y-3">
<Label className="text-foreground font-medium">Preview</Label>
<PromptPreview
format={terminalConfig.promptFormat}
theme={theme}
showGitBranch={terminalConfig.showGitBranch}
showGitStatus={terminalConfig.showGitStatus}
showUserHost={terminalConfig.showUserHost}
showPath={terminalConfig.showPath}
pathStyle={terminalConfig.pathStyle}
pathDepth={terminalConfig.pathDepth}
showTime={terminalConfig.showTime}
showExitStatus={terminalConfig.showExitStatus}
isOmpTheme={isOmpTheme}
promptThemeLabel={promptThemePreset?.label}
/>
</div>
</>
)}
{/* Custom Aliases */}
<div className="space-y-3">
<div className="space-y-1">
<Label className="text-foreground font-medium">Custom Aliases</Label>
<p className="text-xs text-muted-foreground">
Add shell aliases (one per line, e.g., alias ll='ls -la')
</p>
</div>
<Textarea
value={terminalConfig.customAliases}
onChange={(e) => handleUpdateConfig({ customAliases: e.target.value })}
placeholder="# Custom aliases&#10;alias gs='git status'&#10;alias ll='ls -la'&#10;alias ..='cd ..'"
className="font-mono text-sm h-32"
/>
</div>
{/* Custom Environment Variables */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label className="text-foreground font-medium">
Custom Environment Variables
</Label>
<p className="text-xs text-muted-foreground">
Add custom env vars (alphanumeric + underscore only)
</p>
</div>
<Button variant="outline" size="sm" onClick={addEnvVar} className="h-8 gap-1.5">
<Plus className="w-3.5 h-3.5" />
Add
</Button>
</div>
{localEnvVars.length > 0 && (
<div className="space-y-2">
{localEnvVars.map((envVar) => (
<div key={envVar.id} className="flex gap-2 items-start">
<Input
value={envVar.key}
onChange={(e) => updateEnvVar(envVar.id, 'key', e.target.value)}
placeholder="VAR_NAME"
className={cn(
'font-mono text-sm flex-1',
envVar.key &&
!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(envVar.key) &&
'border-destructive'
)}
/>
<Input
value={envVar.value}
onChange={(e) => updateEnvVar(envVar.id, 'value', e.target.value)}
placeholder="value"
className="font-mono text-sm flex-[2]"
/>
<Button
variant="ghost"
size="sm"
onClick={() => removeEnvVar(envVar.id)}
className="h-9 w-9 p-0 text-muted-foreground hover:text-destructive"
>
<X className="w-4 h-4" />
</Button>
</div>
))}
</div>
)}
</div>
</>
)}
</div>
<ConfirmDialog
open={showEnableConfirm}
onOpenChange={setShowEnableConfirm}
title="Enable custom terminal configurations"
description="Automaker will generate per-project shell configuration files for your terminal."
icon={Info}
confirmText="Enable"
onConfirm={() => applyEnabledUpdate(true)}
>
<div className="space-y-3 text-sm text-muted-foreground">
<ul className="list-disc space-y-1 pl-5">
<li>Creates shell config files in `.automaker/terminal/`</li>
<li>Applies prompts and colors that match your app theme</li>
<li>Leaves your existing `~/.bashrc` and `~/.zshrc` untouched</li>
</ul>
<p className="text-xs text-muted-foreground">
New terminal sessions will use the custom prompt; existing sessions are unchanged.
</p>
</div>
</ConfirmDialog>
</div>
);
}

View File

@@ -24,6 +24,7 @@ import { TERMINAL_FONT_OPTIONS } from '@/config/terminal-themes';
import { DEFAULT_FONT_VALUE } from '@/config/ui-font-options';
import { useAvailableTerminals } from '@/components/views/board-view/worktree-panel/hooks/use-available-terminals';
import { getTerminalIcon } from '@/components/icons/terminal-icons';
import { TerminalConfigSection } from './terminal-config-section';
export function TerminalSection() {
const {
@@ -53,253 +54,258 @@ export function TerminalSection() {
const { terminals, isRefreshing, refresh } = useAvailableTerminals();
return (
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-green-500/20 to-green-600/10 flex items-center justify-center border border-green-500/20">
<SquareTerminal className="w-5 h-5 text-green-500" />
<div className="space-y-6">
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-green-500/20 to-green-600/10 flex items-center justify-center border border-green-500/20">
<SquareTerminal className="w-5 h-5 text-green-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">Terminal</h2>
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">Terminal</h2>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Customize terminal appearance and behavior. Theme follows your app theme in Appearance
settings.
</p>
</div>
<div className="p-6 space-y-6">
{/* Default External Terminal */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-foreground font-medium">Default External Terminal</Label>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={refresh}
disabled={isRefreshing}
title="Refresh available terminals"
aria-label="Refresh available terminals"
>
<RefreshCw className={cn('w-3.5 h-3.5', isRefreshing && 'animate-spin')} />
</Button>
</div>
<p className="text-xs text-muted-foreground">
Terminal to use when selecting "Open in Terminal" from the worktree menu
<p className="text-sm text-muted-foreground/80 ml-12">
Customize terminal appearance and behavior. Theme follows your app theme in Appearance
settings.
</p>
<Select
value={defaultTerminalId ?? 'integrated'}
onValueChange={(value) => {
setDefaultTerminalId(value === 'integrated' ? null : value);
toast.success(
value === 'integrated'
? 'Integrated terminal set as default'
: 'Default terminal changed'
);
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a terminal" />
</SelectTrigger>
<SelectContent>
<SelectItem value="integrated">
<span className="flex items-center gap-2">
<Terminal className="w-4 h-4" />
Integrated Terminal
</span>
</SelectItem>
{terminals.map((terminal) => {
const TerminalIcon = getTerminalIcon(terminal.id);
return (
<SelectItem key={terminal.id} value={terminal.id}>
<span className="flex items-center gap-2">
<TerminalIcon className="w-4 h-4" />
{terminal.name}
</span>
</SelectItem>
);
})}
</SelectContent>
</Select>
{terminals.length === 0 && !isRefreshing && (
<p className="text-xs text-muted-foreground italic">
No external terminals detected. Click refresh to re-scan.
</div>
<div className="p-6 space-y-6">
{/* Default External Terminal */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-foreground font-medium">Default External Terminal</Label>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={refresh}
disabled={isRefreshing}
title="Refresh available terminals"
aria-label="Refresh available terminals"
>
<RefreshCw className={cn('w-3.5 h-3.5', isRefreshing && 'animate-spin')} />
</Button>
</div>
<p className="text-xs text-muted-foreground">
Terminal to use when selecting "Open in Terminal" from the worktree menu
</p>
)}
</div>
{/* Default Open Mode */}
<div className="space-y-3">
<Label className="text-foreground font-medium">Default Open Mode</Label>
<p className="text-xs text-muted-foreground">
How to open the integrated terminal when using "Open in Terminal" from the worktree menu
</p>
<Select
value={openTerminalMode}
onValueChange={(value: 'newTab' | 'split') => {
setOpenTerminalMode(value);
toast.success(
value === 'newTab'
? 'New terminals will open in new tabs'
: 'New terminals will split the current tab'
);
}}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="newTab">
<span className="flex items-center gap-2">
<SquarePlus className="w-4 h-4" />
New Tab
</span>
</SelectItem>
<SelectItem value="split">
<span className="flex items-center gap-2">
<SplitSquareHorizontal className="w-4 h-4" />
Split Current Tab
</span>
</SelectItem>
</SelectContent>
</Select>
</div>
{/* Font Family */}
<div className="space-y-3">
<Label className="text-foreground font-medium">Font Family</Label>
<Select
value={fontFamily || DEFAULT_FONT_VALUE}
onValueChange={(value) => {
setTerminalFontFamily(value);
toast.info('Font family changed', {
description: 'Restart terminal for changes to take effect',
});
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Default (Menlo / Monaco)" />
</SelectTrigger>
<SelectContent>
{TERMINAL_FONT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
<span
style={{
fontFamily: option.value === DEFAULT_FONT_VALUE ? undefined : option.value,
}}
>
{option.label}
<Select
value={defaultTerminalId ?? 'integrated'}
onValueChange={(value) => {
setDefaultTerminalId(value === 'integrated' ? null : value);
toast.success(
value === 'integrated'
? 'Integrated terminal set as default'
: 'Default terminal changed'
);
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a terminal" />
</SelectTrigger>
<SelectContent>
<SelectItem value="integrated">
<span className="flex items-center gap-2">
<Terminal className="w-4 h-4" />
Integrated Terminal
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Default Font Size */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-foreground font-medium">Default Font Size</Label>
<span className="text-sm text-muted-foreground">{defaultFontSize}px</span>
{terminals.map((terminal) => {
const TerminalIcon = getTerminalIcon(terminal.id);
return (
<SelectItem key={terminal.id} value={terminal.id}>
<span className="flex items-center gap-2">
<TerminalIcon className="w-4 h-4" />
{terminal.name}
</span>
</SelectItem>
);
})}
</SelectContent>
</Select>
{terminals.length === 0 && !isRefreshing && (
<p className="text-xs text-muted-foreground italic">
No external terminals detected. Click refresh to re-scan.
</p>
)}
</div>
<Slider
value={[defaultFontSize]}
min={8}
max={32}
step={1}
onValueChange={([value]) => setTerminalDefaultFontSize(value)}
className="flex-1"
/>
</div>
{/* Line Height */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-foreground font-medium">Line Height</Label>
<span className="text-sm text-muted-foreground">{lineHeight.toFixed(1)}</span>
</div>
<Slider
value={[lineHeight]}
min={1.0}
max={2.0}
step={0.1}
onValueChange={([value]) => {
setTerminalLineHeight(value);
}}
onValueCommit={() => {
toast.info('Line height changed', {
description: 'Restart terminal for changes to take effect',
});
}}
className="flex-1"
/>
</div>
{/* Scrollback Lines */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-foreground font-medium">Scrollback Buffer</Label>
<span className="text-sm text-muted-foreground">
{(scrollbackLines / 1000).toFixed(0)}k lines
</span>
</div>
<Slider
value={[scrollbackLines]}
min={1000}
max={100000}
step={1000}
onValueChange={([value]) => setTerminalScrollbackLines(value)}
onValueCommit={() => {
toast.info('Scrollback changed', {
description: 'Restart terminal for changes to take effect',
});
}}
className="flex-1"
/>
</div>
{/* Default Run Script */}
<div className="space-y-3">
<Label className="text-foreground font-medium">Default Run Script</Label>
<p className="text-xs text-muted-foreground">
Command to run automatically when opening a new terminal (e.g., "claude", "codex")
</p>
<Input
value={defaultRunScript}
onChange={(e) => setTerminalDefaultRunScript(e.target.value)}
placeholder="e.g., claude, codex, npm run dev"
className="bg-accent/30 border-border/50"
/>
</div>
{/* Screen Reader Mode */}
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label className="text-foreground font-medium">Screen Reader Mode</Label>
{/* Default Open Mode */}
<div className="space-y-3">
<Label className="text-foreground font-medium">Default Open Mode</Label>
<p className="text-xs text-muted-foreground">
Enable accessibility mode for screen readers
How to open the integrated terminal when using "Open in Terminal" from the worktree
menu
</p>
<Select
value={openTerminalMode}
onValueChange={(value: 'newTab' | 'split') => {
setOpenTerminalMode(value);
toast.success(
value === 'newTab'
? 'New terminals will open in new tabs'
: 'New terminals will split the current tab'
);
}}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="newTab">
<span className="flex items-center gap-2">
<SquarePlus className="w-4 h-4" />
New Tab
</span>
</SelectItem>
<SelectItem value="split">
<span className="flex items-center gap-2">
<SplitSquareHorizontal className="w-4 h-4" />
Split Current Tab
</span>
</SelectItem>
</SelectContent>
</Select>
</div>
<Switch
checked={screenReaderMode}
onCheckedChange={(checked) => {
setTerminalScreenReaderMode(checked);
toast.success(
checked ? 'Screen reader mode enabled' : 'Screen reader mode disabled',
{
{/* Font Family */}
<div className="space-y-3">
<Label className="text-foreground font-medium">Font Family</Label>
<Select
value={fontFamily || DEFAULT_FONT_VALUE}
onValueChange={(value) => {
setTerminalFontFamily(value);
toast.info('Font family changed', {
description: 'Restart terminal for changes to take effect',
}
);
}}
/>
});
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Default (Menlo / Monaco)" />
</SelectTrigger>
<SelectContent>
{TERMINAL_FONT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
<span
style={{
fontFamily: option.value === DEFAULT_FONT_VALUE ? undefined : option.value,
}}
>
{option.label}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Default Font Size */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-foreground font-medium">Default Font Size</Label>
<span className="text-sm text-muted-foreground">{defaultFontSize}px</span>
</div>
<Slider
value={[defaultFontSize]}
min={8}
max={32}
step={1}
onValueChange={([value]) => setTerminalDefaultFontSize(value)}
className="flex-1"
/>
</div>
{/* Line Height */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-foreground font-medium">Line Height</Label>
<span className="text-sm text-muted-foreground">{lineHeight.toFixed(1)}</span>
</div>
<Slider
value={[lineHeight]}
min={1.0}
max={2.0}
step={0.1}
onValueChange={([value]) => {
setTerminalLineHeight(value);
}}
onValueCommit={() => {
toast.info('Line height changed', {
description: 'Restart terminal for changes to take effect',
});
}}
className="flex-1"
/>
</div>
{/* Scrollback Lines */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-foreground font-medium">Scrollback Buffer</Label>
<span className="text-sm text-muted-foreground">
{(scrollbackLines / 1000).toFixed(0)}k lines
</span>
</div>
<Slider
value={[scrollbackLines]}
min={1000}
max={100000}
step={1000}
onValueChange={([value]) => setTerminalScrollbackLines(value)}
onValueCommit={() => {
toast.info('Scrollback changed', {
description: 'Restart terminal for changes to take effect',
});
}}
className="flex-1"
/>
</div>
{/* Default Run Script */}
<div className="space-y-3">
<Label className="text-foreground font-medium">Default Run Script</Label>
<p className="text-xs text-muted-foreground">
Command to run automatically when opening a new terminal (e.g., "claude", "codex")
</p>
<Input
value={defaultRunScript}
onChange={(e) => setTerminalDefaultRunScript(e.target.value)}
placeholder="e.g., claude, codex, npm run dev"
className="bg-accent/30 border-border/50"
/>
</div>
{/* Screen Reader Mode */}
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label className="text-foreground font-medium">Screen Reader Mode</Label>
<p className="text-xs text-muted-foreground">
Enable accessibility mode for screen readers
</p>
</div>
<Switch
checked={screenReaderMode}
onCheckedChange={(checked) => {
setTerminalScreenReaderMode(checked);
toast.success(
checked ? 'Screen reader mode enabled' : 'Screen reader mode disabled',
{
description: 'Restart terminal for changes to take effect',
}
);
}}
/>
</div>
</div>
</div>
<TerminalConfigSection />
</div>
);
}

View File

@@ -59,6 +59,7 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps
// CLI Verification state
const [cliVerificationStatus, setCliVerificationStatus] = useState<VerificationStatus>('idle');
const [cliVerificationError, setCliVerificationError] = useState<string | null>(null);
const [cliAuthType, setCliAuthType] = useState<'oauth' | 'cli' | null>(null);
// API Key Verification state
const [apiKeyVerificationStatus, setApiKeyVerificationStatus] =
@@ -119,6 +120,7 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps
const verifyCliAuth = useCallback(async () => {
setCliVerificationStatus('verifying');
setCliVerificationError(null);
setCliAuthType(null);
try {
const api = getElectronAPI();
@@ -138,12 +140,21 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps
if (result.authenticated && !hasLimitReachedError) {
setCliVerificationStatus('verified');
// Store the auth type for displaying specific success message
const authType = result.authType === 'oauth' ? 'oauth' : 'cli';
setCliAuthType(authType);
setClaudeAuthStatus({
authenticated: true,
method: 'cli_authenticated',
method: authType === 'oauth' ? 'oauth_token' : 'cli_authenticated',
hasCredentialsFile: claudeAuthStatus?.hasCredentialsFile || false,
oauthTokenValid: authType === 'oauth',
});
toast.success('Claude CLI authentication verified!');
// Show specific success message based on auth type
if (authType === 'oauth') {
toast.success('Claude Code subscription detected and verified!');
} else {
toast.success('Claude CLI authentication verified!');
}
} else {
setCliVerificationStatus('error');
setCliVerificationError(
@@ -436,9 +447,15 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps
<div className="flex items-center gap-3 p-4 rounded-lg bg-green-500/10 border border-green-500/20">
<CheckCircle2 className="w-5 h-5 text-green-500" />
<div>
<p className="font-medium text-foreground">CLI Authentication verified!</p>
<p className="font-medium text-foreground">
{cliAuthType === 'oauth'
? 'Claude Code subscription verified!'
: 'CLI Authentication verified!'}
</p>
<p className="text-sm text-muted-foreground">
Your Claude CLI is working correctly.
{cliAuthType === 'oauth'
? 'Your Claude Code subscription is active and ready to use.'
: 'Your Claude CLI is working correctly.'}
</p>
</div>
</div>

View File

@@ -32,7 +32,6 @@ function featureToInternal(feature: Feature): FeatureWithId {
}
function internalToFeature(internal: FeatureWithId): Feature {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { _id, _locationIds, ...feature } = internal;
return feature;
}

View File

@@ -27,7 +27,6 @@ function phaseToInternal(phase: RoadmapPhase): PhaseWithId {
}
function internalToPhase(internal: PhaseWithId): RoadmapPhase {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { _id, ...phase } = internal;
return phase;
}

View File

@@ -10,11 +10,12 @@ import { useQuery } from '@tanstack/react-query';
import { getElectronAPI } from '@/lib/electron';
import { queryKeys } from '@/lib/query-keys';
import { STALE_TIMES } from '@/lib/query-client';
import { getGlobalEventsRecent } from '@/hooks/use-event-recency';
import { createSmartPollingInterval, getGlobalEventsRecent } from '@/hooks/use-event-recency';
import type { Feature } from '@/store/app-store';
const FEATURES_REFETCH_ON_FOCUS = false;
const FEATURES_REFETCH_ON_RECONNECT = false;
const FEATURES_POLLING_INTERVAL = 30000;
/** Default polling interval for agent output when WebSocket is inactive */
const AGENT_OUTPUT_POLLING_INTERVAL = 5000;
@@ -43,6 +44,7 @@ export function useFeatures(projectPath: string | undefined) {
},
enabled: !!projectPath,
staleTime: STALE_TIMES.FEATURES,
refetchInterval: createSmartPollingInterval(FEATURES_POLLING_INTERVAL),
refetchOnWindowFocus: FEATURES_REFETCH_ON_FOCUS,
refetchOnReconnect: FEATURES_REFETCH_ON_RECONNECT,
});

View File

@@ -9,9 +9,11 @@ import { useQuery } from '@tanstack/react-query';
import { getElectronAPI, type RunningAgent } from '@/lib/electron';
import { queryKeys } from '@/lib/query-keys';
import { STALE_TIMES } from '@/lib/query-client';
import { createSmartPollingInterval } from '@/hooks/use-event-recency';
const RUNNING_AGENTS_REFETCH_ON_FOCUS = false;
const RUNNING_AGENTS_REFETCH_ON_RECONNECT = false;
const RUNNING_AGENTS_POLLING_INTERVAL = 30000;
interface RunningAgentsResult {
agents: RunningAgent[];
@@ -47,8 +49,7 @@ export function useRunningAgents() {
};
},
staleTime: STALE_TIMES.RUNNING_AGENTS,
// Note: Don't use refetchInterval here - rely on WebSocket invalidation
// for real-time updates instead of polling
refetchInterval: createSmartPollingInterval(RUNNING_AGENTS_POLLING_INTERVAL),
refetchOnWindowFocus: RUNNING_AGENTS_REFETCH_ON_FOCUS,
refetchOnReconnect: RUNNING_AGENTS_REFETCH_ON_RECONNECT,
});

View File

@@ -8,9 +8,11 @@ import { useQuery } from '@tanstack/react-query';
import { getElectronAPI } from '@/lib/electron';
import { queryKeys } from '@/lib/query-keys';
import { STALE_TIMES } from '@/lib/query-client';
import { createSmartPollingInterval } from '@/hooks/use-event-recency';
const WORKTREE_REFETCH_ON_FOCUS = false;
const WORKTREE_REFETCH_ON_RECONNECT = false;
const WORKTREES_POLLING_INTERVAL = 30000;
interface WorktreeInfo {
path: string;
@@ -65,6 +67,7 @@ export function useWorktrees(projectPath: string | undefined, includeDetails = t
},
enabled: !!projectPath,
staleTime: STALE_TIMES.WORKTREES,
refetchInterval: createSmartPollingInterval(WORKTREES_POLLING_INTERVAL),
refetchOnWindowFocus: WORKTREE_REFETCH_ON_FOCUS,
refetchOnReconnect: WORKTREE_REFETCH_ON_RECONNECT,
});

View File

@@ -1,4 +1,4 @@
import { useEffect, useCallback, useMemo } from 'react';
import { useEffect, useCallback, useMemo, useRef } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { createLogger } from '@automaker/utils/logger';
import { DEFAULT_MAX_CONCURRENCY } from '@automaker/types';
@@ -6,11 +6,19 @@ import { useAppStore } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import type { AutoModeEvent } from '@/types/electron';
import type { WorktreeInfo } from '@/components/views/board-view/worktree-panel/types';
import { getGlobalEventsRecent } from '@/hooks/use-event-recency';
const logger = createLogger('AutoMode');
const AUTO_MODE_SESSION_KEY = 'automaker:autoModeRunningByWorktreeKey';
function arraysEqual(a: string[], b: string[]): boolean {
if (a.length !== b.length) return false;
const set = new Set(b);
return a.every((id) => set.has(id));
}
const AUTO_MODE_POLLING_INTERVAL = 30000;
/**
* Generate a worktree key for session storage
* @param projectPath - The project path
@@ -140,42 +148,71 @@ export function useAutoMode(worktree?: WorktreeInfo) {
// Check if we can start a new task based on concurrency limit
const canStartNewTask = runningAutoTasks.length < maxConcurrency;
// On mount, query backend for current auto loop status and sync UI state.
// This handles cases where the backend is still running after a page refresh.
useEffect(() => {
// Ref to prevent refreshStatus from overwriting optimistic state during start/stop
const isTransitioningRef = useRef(false);
const refreshStatus = useCallback(async () => {
if (!currentProject) return;
const syncWithBackend = async () => {
try {
const api = getElectronAPI();
if (!api?.autoMode?.status) return;
// Skip sync when user is in the middle of start/stop - avoids race where
// refreshStatus runs before the API call completes and overwrites optimistic state
if (isTransitioningRef.current) return;
const result = await api.autoMode.status(currentProject.path, branchName);
if (result.success && result.isAutoLoopRunning !== undefined) {
const backendIsRunning = result.isAutoLoopRunning;
try {
const api = getElectronAPI();
if (!api?.autoMode?.status) return;
const result = await api.autoMode.status(currentProject.path, branchName);
if (result.success && result.isAutoLoopRunning !== undefined) {
const backendIsRunning = result.isAutoLoopRunning;
const backendRunningFeatures = result.runningFeatures ?? [];
const needsSync =
backendIsRunning !== isAutoModeRunning ||
// Also sync when backend has runningFeatures we're missing (handles missed WebSocket events)
(backendIsRunning &&
Array.isArray(backendRunningFeatures) &&
backendRunningFeatures.length > 0 &&
!arraysEqual(backendRunningFeatures, runningAutoTasks));
if (needsSync) {
const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
if (backendIsRunning !== isAutoModeRunning) {
const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
logger.info(
`[AutoMode] Syncing UI state with backend for ${worktreeDesc} in ${currentProject.path}: ${backendIsRunning ? 'ON' : 'OFF'}`
);
setAutoModeRunning(
currentProject.id,
branchName,
backendIsRunning,
result.maxConcurrency,
result.runningFeatures
);
setAutoModeSessionForWorktree(currentProject.path, branchName, backendIsRunning);
}
setAutoModeRunning(
currentProject.id,
branchName,
backendIsRunning,
result.maxConcurrency,
backendRunningFeatures
);
setAutoModeSessionForWorktree(currentProject.path, branchName, backendIsRunning);
}
} catch (error) {
logger.error('Error syncing auto mode state with backend:', error);
}
};
} catch (error) {
logger.error('Error syncing auto mode state with backend:', error);
}
}, [branchName, currentProject, isAutoModeRunning, runningAutoTasks, setAutoModeRunning]);
syncWithBackend();
}, [currentProject, branchName, setAutoModeRunning]);
// On mount, query backend for current auto loop status and sync UI state.
// This handles cases where the backend is still running after a page refresh.
useEffect(() => {
void refreshStatus();
}, [refreshStatus]);
// Periodic polling fallback when WebSocket events are stale.
useEffect(() => {
if (!currentProject) return;
const interval = setInterval(() => {
if (getGlobalEventsRecent()) return;
void refreshStatus();
}, AUTO_MODE_POLLING_INTERVAL);
return () => clearInterval(interval);
}, [currentProject, refreshStatus]);
// Handle auto mode events - listen globally for all projects/worktrees
useEffect(() => {
@@ -544,6 +581,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
return;
}
isTransitioningRef.current = true;
try {
const api = getElectronAPI();
if (!api?.autoMode?.start) {
@@ -574,14 +612,18 @@ export function useAutoMode(worktree?: WorktreeInfo) {
}
logger.debug(`[AutoMode] Started successfully for ${worktreeDesc}`);
// Sync with backend after success (gets runningFeatures if events were delayed)
queueMicrotask(() => void refreshStatus());
} catch (error) {
// Revert UI state on error
setAutoModeSessionForWorktree(currentProject.path, branchName, false);
setAutoModeRunning(currentProject.id, branchName, false);
logger.error('Error starting auto mode:', error);
throw error;
} finally {
isTransitioningRef.current = false;
}
}, [currentProject, branchName, setAutoModeRunning]);
}, [currentProject, branchName, setAutoModeRunning, getMaxConcurrencyForWorktree]);
// Stop auto mode - calls backend to stop the auto loop for this worktree
const stop = useCallback(async () => {
@@ -590,6 +632,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
return;
}
isTransitioningRef.current = true;
try {
const api = getElectronAPI();
if (!api?.autoMode?.stop) {
@@ -617,12 +660,16 @@ export function useAutoMode(worktree?: WorktreeInfo) {
// NOTE: Running tasks will continue until natural completion.
// The backend stops picking up new features but doesn't abort running ones.
logger.info(`Stopped ${worktreeDesc} - running tasks will continue`);
// Sync with backend after success
queueMicrotask(() => void refreshStatus());
} catch (error) {
// Revert UI state on error
setAutoModeSessionForWorktree(currentProject.path, branchName, true);
setAutoModeRunning(currentProject.id, branchName, true);
logger.error('Error stopping auto mode:', error);
throw error;
} finally {
isTransitioningRef.current = false;
}
}, [currentProject, branchName, setAutoModeRunning]);
@@ -672,5 +719,6 @@ export function useAutoMode(worktree?: WorktreeInfo) {
start,
stop,
stopFeature,
refreshStatus,
};
}

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