mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-26 00:53:08 +00:00
Compare commits
111 Commits
refactor/a
...
57446b4fba
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57446b4fba | ||
|
|
f06088a062 | ||
|
|
8af1b8bd08 | ||
|
|
d5340fd1a4 | ||
|
|
aa940d44ff | ||
|
|
381698b048 | ||
|
|
30fce3f746 | ||
|
|
4a8c6b0eba | ||
|
|
416ef3a394 | ||
|
|
2805c0ea53 | ||
|
|
727a7a5b9d | ||
|
|
46dd219d15 | ||
|
|
67dd628115 | ||
|
|
ab5d6a0e54 | ||
|
|
0b03e70f1d | ||
|
|
434792a2ef | ||
|
|
462dbf1522 | ||
|
|
eed5e20438 | ||
|
|
41014f6ab6 | ||
|
|
f459b73cb5 | ||
|
|
a935229031 | ||
|
|
a3a5c9e2cb | ||
|
|
1662c6bf0b | ||
|
|
a08ba1b517 | ||
|
|
8226699734 | ||
|
|
d4439fafa0 | ||
|
|
6f1325f3ee | ||
|
|
d4f68b659b | ||
|
|
ad6ce738b4 | ||
|
|
67ebf8c14b | ||
|
|
8ed13564f6 | ||
|
|
09507bff67 | ||
|
|
c70344156d | ||
|
|
8542a32f4f | ||
|
|
0745832d1e | ||
|
|
0f0f5159d2 | ||
|
|
bcc854234c | ||
|
|
5ffbfb3217 | ||
|
|
7c89923a6e | ||
|
|
63b1a353d9 | ||
|
|
49bdaaae71 | ||
|
|
28224e1051 | ||
|
|
df10bcd6df | ||
|
|
0ed4494992 | ||
|
|
43309e383f | ||
|
|
efd4284c10 | ||
|
|
473f935c90 | ||
|
|
7fd3d61a59 | ||
|
|
7bc1f68699 | ||
|
|
ade22ef258 | ||
|
|
31f8afc115 | ||
|
|
071af1b5c3 | ||
|
|
1b32a6bc3a | ||
|
|
a0484624b7 | ||
|
|
0383f85507 | ||
|
|
1a7dd5d1eb | ||
|
|
afa60399dc | ||
|
|
1b39e25497 | ||
|
|
828d0a0148 | ||
|
|
18624d12ce | ||
|
|
71a0309a0b | ||
|
|
e0f785aa99 | ||
|
|
2aa156ecbf | ||
|
|
94a8e09516 | ||
|
|
78072550c7 | ||
|
|
0cd149f2e3 | ||
|
|
2e577bb230 | ||
|
|
4f00b41cb0 | ||
|
|
ba45587a0a | ||
|
|
4912d37990 | ||
|
|
b24839bc49 | ||
|
|
e3a1c8c312 | ||
|
|
8f245e7757 | ||
|
|
cbb45b6612 | ||
|
|
25fa6fd616 | ||
|
|
ec5179eee9 | ||
|
|
2fac438cde | ||
|
|
5dca97dab4 | ||
|
|
58facb114c | ||
|
|
8387b7669d | ||
|
|
18fd1c6caa | ||
|
|
6029e95403 | ||
|
|
1eb28206c5 | ||
|
|
bc9dae0322 | ||
|
|
3bcdc883e6 | ||
|
|
c92c8e96b7 | ||
|
|
b73ef9f801 | ||
|
|
70fc03431c | ||
|
|
a0ea65d483 | ||
|
|
ef544e70c9 | ||
|
|
152cf00735 | ||
|
|
61d43106c8 | ||
|
|
9c304eeec3 | ||
|
|
3563dd55da | ||
|
|
220c8e4ddf | ||
|
|
f97453484f | ||
|
|
835ffe3185 | ||
|
|
3b361cb0b9 | ||
|
|
d06d25b1b5 | ||
|
|
84570842d3 | ||
|
|
63cae19aec | ||
|
|
c9e721bda7 | ||
|
|
d4b7a0c57d | ||
|
|
0b6e84ec6e | ||
|
|
e9c2afcc02 | ||
|
|
88864ad6bc | ||
|
|
0aef72540e | ||
|
|
aad3ff2cdf | ||
|
|
3ccea7a67b | ||
|
|
b37a287c9c | ||
|
|
45f6f17eb0 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -98,3 +98,5 @@ data/
|
||||
|
||||
# GSD planning docs (local-only)
|
||||
.planning/
|
||||
.mcp.json
|
||||
.planning
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
10
Dockerfile
10
Dockerfile
@@ -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
158
LICENSE
@@ -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.
|
||||
|
||||
91
README.md
91
README.md
@@ -14,6 +14,10 @@
|
||||
|
||||
**Stop typing code. Start directing AI agents.**
|
||||
|
||||
> **[!WARNING]**
|
||||
>
|
||||
> **This project is no longer actively maintained.** The codebase is provided as-is. No bug fixes, security updates, or new features are being developed.
|
||||
|
||||
<details open>
|
||||
<summary><h2>Table of Contents</h2></summary>
|
||||
|
||||
@@ -288,6 +292,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 +367,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 +709,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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
║ ║
|
||||
╚═════════════════════════════════════════════════════════════════════╝
|
||||
`);
|
||||
@@ -392,7 +455,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
|
||||
|
||||
@@ -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}`
|
||||
|
||||
25
apps/server/src/lib/terminal-themes-data.ts
Normal file
25
apps/server/src/lib/terminal-themes-data.ts
Normal 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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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 }>)
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
@@ -81,16 +92,16 @@ export class AutoModeServiceCompat {
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -58,6 +58,7 @@ export type {
|
||||
WorktreeCapacityInfo,
|
||||
RunningAgentInfo,
|
||||
OrphanedFeatureInfo,
|
||||
FacadeError,
|
||||
GlobalAutoModeOperations,
|
||||
} from './types.js';
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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, '')
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -107,6 +107,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 +179,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
|
||||
@@ -204,6 +280,8 @@ export class FeatureStateManager {
|
||||
*/
|
||||
async resetStuckFeatures(projectPath: string): Promise<void> {
|
||||
const featuresDir = getFeaturesDir(projectPath);
|
||||
let featuresScanned = 0;
|
||||
let featuresReset = 0;
|
||||
|
||||
try {
|
||||
const entries = await secureFs.readdir(featuresDir, { withFileTypes: true });
|
||||
@@ -211,6 +289,7 @@ export class FeatureStateManager {
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
|
||||
featuresScanned++;
|
||||
const featurePath = path.join(featuresDir, entry.name, 'feature.json');
|
||||
const result = await readJsonWithRecovery<Feature | null>(featurePath, null, {
|
||||
maxBackups: DEFAULT_BACKUP_COUNT,
|
||||
@@ -264,8 +343,13 @@ export class FeatureStateManager {
|
||||
if (needsUpdate) {
|
||||
feature.updatedAt = new Date().toISOString();
|
||||
await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT });
|
||||
featuresReset++;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[resetStuckFeatures] Scanned ${featuresScanned} features, reset ${featuresReset} features for ${projectPath}`
|
||||
);
|
||||
} catch (error) {
|
||||
// If features directory doesn't exist, that's fine
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
@@ -319,7 +403,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 +411,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 +515,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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
106
apps/server/tests/unit/routes/worktree/switch-branch.test.ts
Normal file
106
apps/server/tests/unit/routes/worktree/switch-branch.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -165,6 +165,7 @@ describe('PipelineOrchestrator', () => {
|
||||
|
||||
mockWorktreeResolver = {
|
||||
findWorktreeForBranch: vi.fn().mockResolvedValue('/test/worktree'),
|
||||
getCurrentBranch: vi.fn().mockResolvedValue('main'),
|
||||
} as unknown as WorktreeResolver;
|
||||
|
||||
mockConcurrencyManager = {
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -536,7 +593,7 @@ export function BoardView() {
|
||||
} = useBoardActions({
|
||||
currentProject,
|
||||
features: hookFeatures,
|
||||
runningAutoTasks,
|
||||
runningAutoTasks: runningAutoTasksAllWorktrees,
|
||||
loadFeatures,
|
||||
persistFeatureCreate,
|
||||
persistFeatureUpdate,
|
||||
@@ -825,7 +882,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 +921,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 +938,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 +988,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 +1019,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 +1124,7 @@ export function BoardView() {
|
||||
} = useBoardDragDrop({
|
||||
features: hookFeatures,
|
||||
currentProject,
|
||||
runningAutoTasks,
|
||||
runningAutoTasks: runningAutoTasksAllWorktrees,
|
||||
persistFeatureUpdate,
|
||||
handleStartImplementation,
|
||||
});
|
||||
@@ -1305,10 +1394,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 +1416,7 @@ export function BoardView() {
|
||||
isCreatingSpec={isCreatingSpec}
|
||||
creatingSpecProjectPath={creatingSpecProjectPath}
|
||||
onShowBoardBackground={() => setShowBoardBackgroundModal(true)}
|
||||
onRefreshBoard={refreshBoardState}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
/>
|
||||
@@ -1408,7 +1504,7 @@ export function BoardView() {
|
||||
setShowAddDialog(true);
|
||||
},
|
||||
}}
|
||||
runningAutoTasks={runningAutoTasks}
|
||||
runningAutoTasks={runningAutoTasksAllWorktrees}
|
||||
pipelineConfig={pipelineConfig}
|
||||
onAddFeature={() => setShowAddDialog(true)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
@@ -1447,7 +1543,7 @@ export function BoardView() {
|
||||
setShowAddDialog(true);
|
||||
}}
|
||||
featuresWithContext={featuresWithContext}
|
||||
runningAutoTasks={runningAutoTasks}
|
||||
runningAutoTasks={runningAutoTasksAllWorktrees}
|
||||
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
|
||||
onAddFeature={() => setShowAddDialog(true)}
|
||||
onShowCompletedModal={() => setShowCompletedModal(true)}
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// @ts-nocheck - header component props with optional handlers and status variants
|
||||
import { memo, useState } from 'react';
|
||||
import type { DraggableAttributes, DraggableSyntheticListeners } from '@dnd-kit/core';
|
||||
import { Feature } from '@/store/app-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -35,6 +36,8 @@ interface CardHeaderProps {
|
||||
onDelete: () => void;
|
||||
onViewOutput?: () => void;
|
||||
onSpawnTask?: () => void;
|
||||
dragHandleListeners?: DraggableSyntheticListeners;
|
||||
dragHandleAttributes?: DraggableAttributes;
|
||||
}
|
||||
|
||||
export const CardHeaderSection = memo(function CardHeaderSection({
|
||||
@@ -46,6 +49,8 @@ export const CardHeaderSection = memo(function CardHeaderSection({
|
||||
onDelete,
|
||||
onViewOutput,
|
||||
onSpawnTask,
|
||||
dragHandleListeners,
|
||||
dragHandleAttributes,
|
||||
}: CardHeaderProps) {
|
||||
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
@@ -126,35 +131,39 @@ 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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Waiting approval / Verified header */}
|
||||
{!isCurrentAutoTask &&
|
||||
@@ -315,8 +324,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>
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -108,6 +108,9 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
currentProject: state.currentProject,
|
||||
}))
|
||||
);
|
||||
// A card in waiting_approval should not display as "actively running" even if
|
||||
// it's still in the runningAutoTasks list. The waiting_approval UI takes precedence.
|
||||
const isActivelyRunning = !!isCurrentAutoTask && feature.status !== 'waiting_approval';
|
||||
const [isLifted, setIsLifted] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -121,6 +124,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 +172,7 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
const isSelectable = isSelectionMode && feature.status === selectionTarget;
|
||||
|
||||
const wrapperClasses = cn(
|
||||
'relative select-none outline-none touch-none transition-transform duration-200 ease-out',
|
||||
'relative select-none outline-none transition-transform duration-200 ease-out',
|
||||
getCursorClass(isOverlay, isDraggable, isSelectable),
|
||||
isOverlay && isLifted && 'scale-105 rotate-1 z-50',
|
||||
// Visual feedback when another card is being dragged over this one
|
||||
@@ -184,10 +189,10 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
// Disable hover translate for in-progress cards to prevent gap showing gradient
|
||||
isInteractive &&
|
||||
!reduceEffects &&
|
||||
!isCurrentAutoTask &&
|
||||
!isActivelyRunning &&
|
||||
'hover:-translate-y-0.5 hover:shadow-md hover:shadow-black/10 bg-transparent',
|
||||
!glassmorphism && 'backdrop-blur-[0px]!',
|
||||
!isCurrentAutoTask &&
|
||||
!isActivelyRunning &&
|
||||
cardBorderEnabled &&
|
||||
(cardBorderOpacity === 100 ? 'border-border/50' : 'border'),
|
||||
hasError && 'border-[var(--status-error)] border-2 shadow-[var(--status-error-bg)] shadow-lg',
|
||||
@@ -204,7 +209,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 +248,14 @@ 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}
|
||||
dragHandleListeners={isDraggable ? listeners : undefined}
|
||||
dragHandleAttributes={isDraggable ? attributes : undefined}
|
||||
/>
|
||||
|
||||
<CardContent className="px-3 pt-0 pb-0">
|
||||
@@ -267,7 +274,7 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
{/* Actions */}
|
||||
<CardActions
|
||||
feature={feature}
|
||||
isCurrentAutoTask={!!isCurrentAutoTask}
|
||||
isCurrentAutoTask={isActivelyRunning}
|
||||
hasContext={hasContext}
|
||||
shortcutKey={shortcutKey}
|
||||
isSelectionMode={isSelectionMode}
|
||||
@@ -291,12 +298,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()
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -75,27 +75,25 @@ 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');
|
||||
}
|
||||
|
||||
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),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to persist feature creation:', error);
|
||||
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),
|
||||
});
|
||||
} else if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to create feature on server');
|
||||
}
|
||||
},
|
||||
[currentProject, updateFeature, queryClient]
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'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 alias gs='git status' alias ll='ls -la' 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -28,8 +28,11 @@ const PROGRESS_DEBOUNCE_MAX_WAIT = 2000;
|
||||
* feature moving to custom pipeline columns (fixes GitHub issue #668)
|
||||
*/
|
||||
const FEATURE_LIST_INVALIDATION_EVENTS: AutoModeEvent['type'][] = [
|
||||
'auto_mode_feature_start',
|
||||
'auto_mode_feature_complete',
|
||||
'auto_mode_error',
|
||||
'auto_mode_started',
|
||||
'auto_mode_stopped',
|
||||
'plan_approval_required',
|
||||
'plan_approved',
|
||||
'plan_rejected',
|
||||
@@ -39,11 +42,11 @@ const FEATURE_LIST_INVALIDATION_EVENTS: AutoModeEvent['type'][] = [
|
||||
|
||||
/**
|
||||
* Events that should invalidate a specific feature (features.single query)
|
||||
* Note: pipeline_step_started is NOT included here because it already invalidates
|
||||
* features.all() above, which also invalidates child queries (features.single)
|
||||
* Note: auto_mode_feature_start and pipeline_step_started are NOT included here
|
||||
* because they already invalidate features.all() above, which also invalidates
|
||||
* child queries (features.single)
|
||||
*/
|
||||
const SINGLE_FEATURE_INVALIDATION_EVENTS: AutoModeEvent['type'][] = [
|
||||
'auto_mode_feature_start',
|
||||
'auto_mode_phase',
|
||||
'auto_mode_phase_complete',
|
||||
'auto_mode_task_status',
|
||||
|
||||
@@ -27,18 +27,20 @@ export interface AgentTaskInfo {
|
||||
/**
|
||||
* Default model used by the feature executor
|
||||
*/
|
||||
export const DEFAULT_MODEL = 'claude-opus-4-5-20251101';
|
||||
export const DEFAULT_MODEL = 'claude-opus-4-6';
|
||||
|
||||
/**
|
||||
* Formats a model name for display
|
||||
*/
|
||||
export function formatModelName(model: string): string {
|
||||
// Claude models
|
||||
if (model.includes('opus-4-6') || model === 'claude-opus') return 'Opus 4.6';
|
||||
if (model.includes('opus')) return 'Opus 4.5';
|
||||
if (model.includes('sonnet')) return 'Sonnet 4.5';
|
||||
if (model.includes('haiku')) return 'Haiku 4.5';
|
||||
|
||||
// Codex/GPT models - specific formatting
|
||||
if (model === 'codex-gpt-5.3-codex') return 'GPT-5.3 Codex';
|
||||
if (model === 'codex-gpt-5.2-codex') return 'GPT-5.2 Codex';
|
||||
if (model === 'codex-gpt-5.2') return 'GPT-5.2';
|
||||
if (model === 'codex-gpt-5.1-codex-max') return 'GPT-5.1 Max';
|
||||
|
||||
@@ -1442,6 +1442,7 @@ interface SetupAPI {
|
||||
verifyClaudeAuth: (authMethod?: 'cli' | 'api_key') => Promise<{
|
||||
success: boolean;
|
||||
authenticated: boolean;
|
||||
authType?: 'oauth' | 'api_key' | 'cli';
|
||||
error?: string;
|
||||
}>;
|
||||
getGhStatus?: () => Promise<{
|
||||
|
||||
@@ -1350,6 +1350,7 @@ export class HttpApiClient implements ElectronAPI {
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
authenticated: boolean;
|
||||
authType?: 'oauth' | 'api_key' | 'cli';
|
||||
error?: string;
|
||||
}> => this.post('/api/setup/verify-claude-auth', { authMethod, apiKey }),
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import { createLogger } from '@automaker/utils/logger';
|
||||
// Note: setItem/getItem moved to ./utils/theme-utils.ts
|
||||
import { UI_SANS_FONT_OPTIONS, UI_MONO_FONT_OPTIONS } from '@/config/ui-font-options';
|
||||
import type {
|
||||
Feature as BaseFeature,
|
||||
FeatureImagePath,
|
||||
FeatureTextFilePath,
|
||||
ModelAlias,
|
||||
@@ -15,25 +14,11 @@ import type {
|
||||
ThinkingLevel,
|
||||
ReasoningEffort,
|
||||
ModelProvider,
|
||||
CursorModelId,
|
||||
CodexModelId,
|
||||
OpencodeModelId,
|
||||
GeminiModelId,
|
||||
CopilotModelId,
|
||||
PhaseModelConfig,
|
||||
PhaseModelKey,
|
||||
PhaseModelEntry,
|
||||
MCPServerConfig,
|
||||
FeatureStatusWithPipeline,
|
||||
PipelineConfig,
|
||||
PipelineStep,
|
||||
PromptCustomization,
|
||||
ModelDefinition,
|
||||
ServerLogLevel,
|
||||
EventHook,
|
||||
ClaudeApiProfile,
|
||||
ClaudeCompatibleProvider,
|
||||
SidebarStyle,
|
||||
ParsedTask,
|
||||
PlanSpec,
|
||||
} from '@automaker/types';
|
||||
@@ -2131,7 +2116,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
const updateSizes = (layout: TerminalPanelContent): TerminalPanelContent => {
|
||||
if (layout.type === 'split') {
|
||||
// Find matching panels and update sizes
|
||||
const updatedPanels = layout.panels.map((panel, index) => {
|
||||
const updatedPanels = layout.panels.map((panel, _index) => {
|
||||
// Generate key for this panel
|
||||
const panelKey =
|
||||
panel.type === 'split'
|
||||
|
||||
@@ -2,8 +2,6 @@ import type { Project, TrashedProject } from '@/lib/electron';
|
||||
import type {
|
||||
ModelAlias,
|
||||
PlanningMode,
|
||||
ThinkingLevel,
|
||||
ReasoningEffort,
|
||||
ModelProvider,
|
||||
CursorModelId,
|
||||
CodexModelId,
|
||||
@@ -33,7 +31,7 @@ import type {
|
||||
BackgroundSettings,
|
||||
} from './ui-types';
|
||||
import type { ApiKeys } from './settings-types';
|
||||
import type { ChatMessage, ChatSession, FeatureImage } from './chat-types';
|
||||
import type { ChatMessage, ChatSession } from './chat-types';
|
||||
import type { TerminalState, TerminalPanelContent, PersistedTerminalState } from './terminal-types';
|
||||
import type { Feature, ProjectAnalysis } from './project-types';
|
||||
import type { ClaudeUsage, CodexUsage } from './usage-types';
|
||||
|
||||
@@ -1,5 +1,64 @@
|
||||
import type { ClaudeUsage } from '../types/usage-types';
|
||||
|
||||
/**
|
||||
* Calculate the expected weekly usage percentage based on how far through the week we are.
|
||||
* Claude's weekly usage resets every Thursday. Given the reset time (when the NEXT reset occurs),
|
||||
* we can determine how much of the week has elapsed and therefore what percentage of the budget
|
||||
* should have been used if usage were evenly distributed.
|
||||
*
|
||||
* @param weeklyResetTime - ISO date string for when the weekly usage next resets
|
||||
* @returns The expected usage percentage (0-100), or null if the reset time is invalid
|
||||
*/
|
||||
export function getExpectedWeeklyPacePercentage(
|
||||
weeklyResetTime: string | undefined
|
||||
): number | null {
|
||||
if (!weeklyResetTime) return null;
|
||||
|
||||
try {
|
||||
const resetDate = new Date(weeklyResetTime);
|
||||
if (isNaN(resetDate.getTime())) return null;
|
||||
|
||||
const now = new Date();
|
||||
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
// The week started 7 days before the reset
|
||||
const weekStartDate = new Date(resetDate.getTime() - WEEK_MS);
|
||||
|
||||
// How far through the week are we?
|
||||
const elapsed = now.getTime() - weekStartDate.getTime();
|
||||
const fractionElapsed = elapsed / WEEK_MS;
|
||||
|
||||
// Clamp to 0-1 range
|
||||
const clamped = Math.max(0, Math.min(1, fractionElapsed));
|
||||
|
||||
return clamped * 100;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a human-readable label for the pace status (ahead or behind expected usage).
|
||||
*
|
||||
* @param actualPercentage - The actual usage percentage (0-100)
|
||||
* @param expectedPercentage - The expected usage percentage (0-100)
|
||||
* @returns A string like "5% ahead of pace" or "10% behind pace", or null
|
||||
*/
|
||||
export function getPaceStatusLabel(
|
||||
actualPercentage: number,
|
||||
expectedPercentage: number | null
|
||||
): string | null {
|
||||
if (expectedPercentage === null) return null;
|
||||
|
||||
const diff = Math.round(actualPercentage - expectedPercentage);
|
||||
|
||||
if (diff === 0) return 'On pace';
|
||||
// Using more than expected = behind pace (bad)
|
||||
if (diff > 0) return `${Math.abs(diff)}% behind pace`;
|
||||
// Using less than expected = ahead of pace (good)
|
||||
return `${Math.abs(diff)}% ahead of pace`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Claude usage is at its limit (any of: session >= 100%, weekly >= 100%, OR cost >= limit)
|
||||
* Returns true if any limit is reached, meaning auto mode should pause feature pickup.
|
||||
|
||||
@@ -80,6 +80,7 @@ test.describe('Edit Feature', () => {
|
||||
await clickAddFeature(page);
|
||||
await fillAddFeatureDialog(page, originalDescription);
|
||||
await confirmAddFeature(page);
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Wait for the feature to appear in the backlog
|
||||
await expect(async () => {
|
||||
@@ -88,7 +89,7 @@ test.describe('Edit Feature', () => {
|
||||
hasText: originalDescription,
|
||||
});
|
||||
expect(await featureCard.count()).toBeGreaterThan(0);
|
||||
}).toPass({ timeout: 10000 });
|
||||
}).toPass({ timeout: 20000 });
|
||||
|
||||
// Get the feature ID from the card
|
||||
const featureCard = page
|
||||
|
||||
@@ -21,9 +21,13 @@ services:
|
||||
# - ~/.local/share/opencode:/home/automaker/.local/share/opencode
|
||||
# - ~/.config/opencode:/home/automaker/.config/opencode
|
||||
|
||||
# Playwright browser cache - persists installed browsers across container restarts
|
||||
# Run 'npx playwright install --with-deps chromium' once, and it will persist
|
||||
# ===== Playwright Browser Cache (Optional) =====
|
||||
# Playwright Chromium is PRE-INSTALLED in the Docker image for automated testing.
|
||||
# Uncomment below to persist browser cache across container rebuilds (saves ~300MB download):
|
||||
# - playwright-cache:/home/automaker/.cache/ms-playwright
|
||||
#
|
||||
# To update Playwright browsers manually:
|
||||
# docker exec --user automaker -w /app automaker-server npx playwright install chromium
|
||||
environment:
|
||||
# Set root directory for all projects and file operations
|
||||
# Users can only create/open projects within this directory
|
||||
@@ -37,6 +41,7 @@ services:
|
||||
# - CURSOR_API_KEY=${CURSOR_API_KEY:-}
|
||||
|
||||
volumes:
|
||||
# Playwright cache volume (persists Chromium installs)
|
||||
# Playwright cache volume - optional, persists browser updates across container rebuilds
|
||||
# Uncomment if you mounted the playwright-cache volume above
|
||||
# playwright-cache:
|
||||
# name: automaker-playwright-cache
|
||||
|
||||
@@ -142,7 +142,7 @@ const modelId = resolveModelString('sonnet'); // → 'claude-sonnet-4-20250514'
|
||||
|
||||
- `haiku` → `claude-haiku-4-5` (fast, simple tasks)
|
||||
- `sonnet` → `claude-sonnet-4-20250514` (balanced, recommended)
|
||||
- `opus` → `claude-opus-4-5-20251101` (maximum capability)
|
||||
- `opus` → `claude-opus-4-6` (maximum capability)
|
||||
|
||||
### @automaker/dependency-resolver
|
||||
|
||||
|
||||
BIN
docs/pr/terminal-omp.png
Normal file
BIN
docs/pr/terminal-omp.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
@@ -175,7 +175,7 @@ Uses `@anthropic-ai/claude-agent-sdk` for direct SDK integration.
|
||||
|
||||
Routes models that:
|
||||
|
||||
- Start with `"claude-"` (e.g., `"claude-opus-4-5-20251101"`)
|
||||
- Start with `"claude-"` (e.g., `"claude-opus-4-6"`)
|
||||
- Are Claude aliases: `"opus"`, `"sonnet"`, `"haiku"`
|
||||
|
||||
#### Authentication
|
||||
@@ -191,7 +191,7 @@ const provider = new ClaudeProvider();
|
||||
|
||||
const stream = provider.executeQuery({
|
||||
prompt: 'What is 2+2?',
|
||||
model: 'claude-opus-4-5-20251101',
|
||||
model: 'claude-opus-4-6',
|
||||
cwd: '/project/path',
|
||||
systemPrompt: 'You are a helpful assistant.',
|
||||
maxTurns: 20,
|
||||
@@ -701,7 +701,7 @@ Test provider interaction with services:
|
||||
```typescript
|
||||
describe('Provider Integration', () => {
|
||||
it('should work with AgentService', async () => {
|
||||
const provider = ProviderFactory.getProviderForModel('claude-opus-4-5-20251101');
|
||||
const provider = ProviderFactory.getProviderForModel('claude-opus-4-6');
|
||||
|
||||
// Test full workflow
|
||||
});
|
||||
|
||||
@@ -213,7 +213,7 @@ Model alias mapping for Claude models.
|
||||
export const CLAUDE_MODEL_MAP: Record<string, string> = {
|
||||
haiku: 'claude-haiku-4-5',
|
||||
sonnet: 'claude-sonnet-4-20250514',
|
||||
opus: 'claude-opus-4-5-20251101',
|
||||
opus: 'claude-opus-4-6',
|
||||
} as const;
|
||||
```
|
||||
|
||||
@@ -223,7 +223,7 @@ Default models per provider.
|
||||
|
||||
```typescript
|
||||
export const DEFAULT_MODELS = {
|
||||
claude: 'claude-opus-4-5-20251101',
|
||||
claude: 'claude-opus-4-6',
|
||||
openai: 'gpt-5.2',
|
||||
} as const;
|
||||
```
|
||||
@@ -248,8 +248,8 @@ Resolve a model key/alias to a full model string.
|
||||
import { resolveModelString, DEFAULT_MODELS } from '../lib/model-resolver.js';
|
||||
|
||||
resolveModelString('opus');
|
||||
// Returns: "claude-opus-4-5-20251101"
|
||||
// Logs: "[ModelResolver] Resolved model alias: "opus" -> "claude-opus-4-5-20251101""
|
||||
// Returns: "claude-opus-4-6"
|
||||
// Logs: "[ModelResolver] Resolved model alias: "opus" -> "claude-opus-4-6""
|
||||
|
||||
resolveModelString('gpt-5.2');
|
||||
// Returns: "gpt-5.2"
|
||||
@@ -260,8 +260,8 @@ resolveModelString('claude-sonnet-4-20250514');
|
||||
// Logs: "[ModelResolver] Using full Claude model string: claude-sonnet-4-20250514"
|
||||
|
||||
resolveModelString('invalid-model');
|
||||
// Returns: "claude-opus-4-5-20251101"
|
||||
// Logs: "[ModelResolver] Unknown model key "invalid-model", using default: "claude-opus-4-5-20251101""
|
||||
// Returns: "claude-opus-4-6"
|
||||
// Logs: "[ModelResolver] Unknown model key "invalid-model", using default: "claude-opus-4-6""
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
632
docs/terminal-custom-configs-plan.md
Normal file
632
docs/terminal-custom-configs-plan.md
Normal file
@@ -0,0 +1,632 @@
|
||||
# Implementation Plan: Custom Terminal Configurations with Theme Synchronization
|
||||
|
||||
## Overview
|
||||
|
||||
Implement custom shell configuration files (.bashrc, .zshrc) that automatically sync with Automaker's 40 themes, providing a seamless terminal experience where prompt colors match the app theme. This is an **opt-in feature** that creates configs in `.automaker/terminal/` without modifying user's existing RC files.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Core Components
|
||||
|
||||
1. **RC Generator** (`libs/platform/src/rc-generator.ts`) - NEW
|
||||
- Template-based generation for bash/zsh/sh
|
||||
- Theme-to-ANSI color mapping from hex values
|
||||
- Git info integration (branch, dirty status)
|
||||
- Prompt format templates (standard, minimal, powerline, starship-inspired)
|
||||
|
||||
2. **RC File Manager** (`libs/platform/src/rc-file-manager.ts`) - NEW
|
||||
- File I/O for `.automaker/terminal/` directory
|
||||
- Version checking and regeneration logic
|
||||
- Path resolution for different shells
|
||||
|
||||
3. **Terminal Service** (`apps/server/src/services/terminal-service.ts`) - MODIFY
|
||||
- Inject BASH_ENV/ZDOTDIR environment variables when spawning PTY
|
||||
- Hook for theme change regeneration
|
||||
- Backwards compatible (no change when disabled)
|
||||
|
||||
4. **Settings Schema** (`libs/types/src/settings.ts`) - MODIFY
|
||||
- Add `terminalConfig` to GlobalSettings and ProjectSettings
|
||||
- Include enable toggle, prompt format, git info toggles, custom aliases/env vars
|
||||
|
||||
5. **Settings UI** (`apps/ui/src/components/views/settings-view/terminal/terminal-config-section.tsx`) - NEW
|
||||
- Enable/disable toggle with explanation
|
||||
- Prompt format selector (4 formats)
|
||||
- Git info toggles (branch/status)
|
||||
- Custom aliases textarea
|
||||
- Custom env vars key-value editor
|
||||
- Live preview panel showing example prompt
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
.automaker/terminal/
|
||||
├── bashrc.sh # Bash config (sourced via BASH_ENV)
|
||||
├── zshrc.zsh # Zsh config (via ZDOTDIR)
|
||||
├── common.sh # Shared functions (git prompt, etc.)
|
||||
├── themes/
|
||||
│ ├── dark.sh # Theme-specific color exports (40 files)
|
||||
│ ├── dracula.sh
|
||||
│ ├── nord.sh
|
||||
│ └── ... (38 more)
|
||||
├── version.txt # RC file format version (for migrations)
|
||||
└── user-custom.sh # User's additional customizations (optional)
|
||||
```
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Create RC Generator Package
|
||||
|
||||
**File**: `libs/platform/src/rc-generator.ts`
|
||||
|
||||
**Key Functions**:
|
||||
|
||||
```typescript
|
||||
// Main generation functions
|
||||
export function generateBashrc(theme: ThemeMode, config: TerminalConfig): string;
|
||||
export function generateZshrc(theme: ThemeMode, config: TerminalConfig): string;
|
||||
export function generateCommonFunctions(): string;
|
||||
export function generateThemeColors(theme: ThemeMode): string;
|
||||
|
||||
// Color mapping
|
||||
export function hexToXterm256(hex: string): number;
|
||||
export function getThemeANSIColors(terminalTheme: TerminalTheme): ANSIColors;
|
||||
```
|
||||
|
||||
**Templates**:
|
||||
|
||||
- Source user's original ~/.bashrc or ~/.zshrc first
|
||||
- Load theme colors from `themes/${AUTOMAKER_THEME}.sh`
|
||||
- Set custom PS1/PROMPT only if `AUTOMAKER_CUSTOM_PROMPT=true`
|
||||
- Include git prompt function: `automaker_git_prompt()`
|
||||
|
||||
**Example bashrc.sh template**:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Automaker Terminal Configuration v1.0
|
||||
|
||||
# Source user's original bashrc first
|
||||
if [ -f "$HOME/.bashrc" ]; then
|
||||
source "$HOME/.bashrc"
|
||||
fi
|
||||
|
||||
# Load Automaker theme colors
|
||||
AUTOMAKER_THEME="${AUTOMAKER_THEME:-dark}"
|
||||
if [ -f "${BASH_SOURCE%/*}/themes/$AUTOMAKER_THEME.sh" ]; then
|
||||
source "${BASH_SOURCE%/*}/themes/$AUTOMAKER_THEME.sh"
|
||||
fi
|
||||
|
||||
# Load common functions (git prompt)
|
||||
source "${BASH_SOURCE%/*}/common.sh"
|
||||
|
||||
# Set custom prompt (only if enabled)
|
||||
if [ "$AUTOMAKER_CUSTOM_PROMPT" = "true" ]; then
|
||||
PS1="\[$COLOR_USER\]\u@\h\[$COLOR_RESET\] "
|
||||
PS1="$PS1\[$COLOR_PATH\]\w\[$COLOR_RESET\]"
|
||||
PS1="$PS1\$(automaker_git_prompt) "
|
||||
PS1="$PS1\[$COLOR_PROMPT\]\$\[$COLOR_RESET\] "
|
||||
fi
|
||||
|
||||
# Load user customizations (if exists)
|
||||
if [ -f "${BASH_SOURCE%/*}/user-custom.sh" ]; then
|
||||
source "${BASH_SOURCE%/*}/user-custom.sh"
|
||||
fi
|
||||
```
|
||||
|
||||
**Color Mapping Algorithm**:
|
||||
|
||||
1. Get hex colors from `apps/ui/src/config/terminal-themes.ts` (TerminalTheme interface)
|
||||
2. Convert hex to RGB
|
||||
3. Map to closest xterm-256 color code using Euclidean distance in RGB space
|
||||
4. Generate ANSI escape codes: `\[\e[38;5;{code}m\]` for foreground
|
||||
|
||||
### Step 2: Create RC File Manager
|
||||
|
||||
**File**: `libs/platform/src/rc-file-manager.ts`
|
||||
|
||||
**Key Functions**:
|
||||
|
||||
```typescript
|
||||
export async function ensureTerminalDir(projectPath: string): Promise<void>;
|
||||
export async function writeRcFiles(
|
||||
projectPath: string,
|
||||
theme: ThemeMode,
|
||||
config: TerminalConfig
|
||||
): Promise<void>;
|
||||
export function getRcFilePath(projectPath: string, shell: 'bash' | 'zsh' | 'sh'): string;
|
||||
export async function checkRcFileVersion(projectPath: string): Promise<number | null>;
|
||||
export async function needsRegeneration(
|
||||
projectPath: string,
|
||||
theme: ThemeMode,
|
||||
config: TerminalConfig
|
||||
): Promise<boolean>;
|
||||
```
|
||||
|
||||
**File Operations**:
|
||||
|
||||
- Create `.automaker/terminal/` if doesn't exist
|
||||
- Write RC files with 0644 permissions
|
||||
- Write theme color files (40 themes × 1 file each)
|
||||
- Create version.txt with format version (currently "11")
|
||||
- Support atomic writes (write to temp, then rename)
|
||||
|
||||
### Step 3: Add Settings Schema
|
||||
|
||||
**File**: `libs/types/src/settings.ts`
|
||||
|
||||
**Add to GlobalSettings** (around line 842):
|
||||
|
||||
```typescript
|
||||
/** Terminal configuration settings */
|
||||
terminalConfig?: {
|
||||
/** Enable custom terminal configurations (default: false) */
|
||||
enabled: boolean;
|
||||
|
||||
/** Enable custom prompt (default: true when enabled) */
|
||||
customPrompt: boolean;
|
||||
|
||||
/** Prompt format template */
|
||||
promptFormat: 'standard' | 'minimal' | 'powerline' | 'starship';
|
||||
|
||||
/** Prompt theme preset */
|
||||
promptTheme?: TerminalPromptTheme;
|
||||
|
||||
/** Show git branch in prompt (default: true) */
|
||||
showGitBranch: boolean;
|
||||
|
||||
/** Show git status dirty indicator (default: true) */
|
||||
showGitStatus: boolean;
|
||||
|
||||
/** Show user and host in prompt (default: true) */
|
||||
showUserHost: boolean;
|
||||
|
||||
/** Show path in prompt (default: true) */
|
||||
showPath: boolean;
|
||||
|
||||
/** Path display style */
|
||||
pathStyle: 'full' | 'short' | 'basename';
|
||||
|
||||
/** Limit path depth (0 = full path) */
|
||||
pathDepth: number;
|
||||
|
||||
/** Show current time in prompt (default: false) */
|
||||
showTime: boolean;
|
||||
|
||||
/** Show last command exit status when non-zero (default: false) */
|
||||
showExitStatus: boolean;
|
||||
|
||||
/** User-provided custom aliases (multiline string) */
|
||||
customAliases: string;
|
||||
|
||||
/** User-provided custom env vars */
|
||||
customEnvVars: Record<string, string>;
|
||||
|
||||
/** RC file format version (for migration) */
|
||||
rcFileVersion?: number;
|
||||
};
|
||||
```
|
||||
|
||||
**Add to ProjectSettings**:
|
||||
|
||||
```typescript
|
||||
/** Project-specific terminal config overrides */
|
||||
terminalConfig?: {
|
||||
/** Override global enabled setting */
|
||||
enabled?: boolean;
|
||||
|
||||
/** Override prompt theme preset */
|
||||
promptTheme?: TerminalPromptTheme;
|
||||
|
||||
/** Override showing user/host */
|
||||
showUserHost?: boolean;
|
||||
|
||||
/** Override showing path */
|
||||
showPath?: boolean;
|
||||
|
||||
/** Override path style */
|
||||
pathStyle?: 'full' | 'short' | 'basename';
|
||||
|
||||
/** Override path depth (0 = full path) */
|
||||
pathDepth?: number;
|
||||
|
||||
/** Override showing time */
|
||||
showTime?: boolean;
|
||||
|
||||
/** Override showing exit status */
|
||||
showExitStatus?: boolean;
|
||||
|
||||
/** Project-specific custom aliases */
|
||||
customAliases?: string;
|
||||
|
||||
/** Project-specific env vars */
|
||||
customEnvVars?: Record<string, string>;
|
||||
|
||||
/** Custom welcome message for this project */
|
||||
welcomeMessage?: string;
|
||||
};
|
||||
```
|
||||
|
||||
**Defaults**:
|
||||
|
||||
```typescript
|
||||
const DEFAULT_TERMINAL_CONFIG = {
|
||||
enabled: false,
|
||||
customPrompt: true,
|
||||
promptFormat: 'standard' as const,
|
||||
promptTheme: 'custom' as const,
|
||||
showGitBranch: true,
|
||||
showGitStatus: true,
|
||||
showUserHost: true,
|
||||
showPath: true,
|
||||
pathStyle: 'full' as const,
|
||||
pathDepth: 0,
|
||||
showTime: false,
|
||||
showExitStatus: false,
|
||||
customAliases: '',
|
||||
customEnvVars: {},
|
||||
rcFileVersion: 11,
|
||||
};
|
||||
```
|
||||
|
||||
**Oh My Posh Themes**:
|
||||
|
||||
- When `promptTheme` starts with `omp-` and `oh-my-posh` is available, the generated RC files will
|
||||
initialize oh-my-posh with the selected theme name.
|
||||
- If oh-my-posh is not installed, the prompt falls back to the Automaker-built prompt format.
|
||||
- `POSH_THEMES_PATH` is exported to the standard user themes directory so themes resolve offline.
|
||||
|
||||
### Step 4: Modify Terminal Service
|
||||
|
||||
**File**: `apps/server/src/services/terminal-service.ts`
|
||||
|
||||
**Modification Point**: In `createSession()` method, around line 335-344 where `env` object is built.
|
||||
|
||||
**Add before PTY spawn**:
|
||||
|
||||
```typescript
|
||||
// Get terminal config from settings
|
||||
const terminalConfig = await this.settingsService?.getGlobalSettings();
|
||||
const projectSettings = options.projectPath
|
||||
? await this.settingsService?.getProjectSettings(options.projectPath)
|
||||
: null;
|
||||
|
||||
const effectiveTerminalConfig = {
|
||||
...terminalConfig?.terminalConfig,
|
||||
...projectSettings?.terminalConfig,
|
||||
};
|
||||
|
||||
if (effectiveTerminalConfig?.enabled) {
|
||||
// Ensure RC files are up to date
|
||||
const currentTheme = terminalConfig?.theme || 'dark';
|
||||
await ensureRcFilesUpToDate(options.projectPath || cwd, currentTheme, effectiveTerminalConfig);
|
||||
|
||||
// Set shell-specific env vars
|
||||
const shellName = path.basename(shell).toLowerCase();
|
||||
|
||||
if (shellName.includes('bash')) {
|
||||
env.BASH_ENV = getRcFilePath(options.projectPath || cwd, 'bash');
|
||||
env.AUTOMAKER_CUSTOM_PROMPT = effectiveTerminalConfig.customPrompt ? 'true' : 'false';
|
||||
env.AUTOMAKER_THEME = currentTheme;
|
||||
} else if (shellName.includes('zsh')) {
|
||||
env.ZDOTDIR = path.join(options.projectPath || cwd, '.automaker', 'terminal');
|
||||
env.AUTOMAKER_CUSTOM_PROMPT = effectiveTerminalConfig.customPrompt ? 'true' : 'false';
|
||||
env.AUTOMAKER_THEME = currentTheme;
|
||||
} else if (shellName === 'sh') {
|
||||
env.ENV = getRcFilePath(options.projectPath || cwd, 'sh');
|
||||
env.AUTOMAKER_CUSTOM_PROMPT = effectiveTerminalConfig.customPrompt ? 'true' : 'false';
|
||||
env.AUTOMAKER_THEME = currentTheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Add new method for theme changes**:
|
||||
|
||||
```typescript
|
||||
async onThemeChange(projectPath: string, newTheme: ThemeMode): Promise<void> {
|
||||
const globalSettings = await this.settingsService?.getGlobalSettings();
|
||||
const terminalConfig = globalSettings?.terminalConfig;
|
||||
|
||||
if (terminalConfig?.enabled) {
|
||||
// Regenerate RC files with new theme
|
||||
await writeRcFiles(projectPath, newTheme, terminalConfig);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5: Create Settings UI
|
||||
|
||||
**File**: `apps/ui/src/components/views/settings-view/terminal/terminal-config-section.tsx`
|
||||
|
||||
**Component Structure**:
|
||||
|
||||
```typescript
|
||||
export function TerminalConfigSection() {
|
||||
return (
|
||||
<div>
|
||||
{/* Enable Toggle with Warning */}
|
||||
<div>
|
||||
<Label>Custom Terminal Configurations</Label>
|
||||
<Switch checked={enabled} onCheckedChange={handleToggle} />
|
||||
<p>Creates custom shell configs in .automaker/terminal/</p>
|
||||
</div>
|
||||
|
||||
{enabled && (
|
||||
<>
|
||||
{/* Custom Prompt Toggle */}
|
||||
<Switch checked={customPrompt} />
|
||||
|
||||
{/* Prompt Format Selector */}
|
||||
<Select value={promptFormat} onValueChange={setPromptFormat}>
|
||||
<option value="standard">Standard</option>
|
||||
<option value="minimal">Minimal</option>
|
||||
<option value="powerline">Powerline</option>
|
||||
<option value="starship">Starship-Inspired</option>
|
||||
</Select>
|
||||
|
||||
{/* Git Info Toggles */}
|
||||
<Switch checked={showGitBranch} label="Show Git Branch" />
|
||||
<Switch checked={showGitStatus} label="Show Git Status" />
|
||||
|
||||
{/* Custom Aliases */}
|
||||
<Textarea
|
||||
value={customAliases}
|
||||
placeholder="# Custom aliases\nalias ll='ls -la'"
|
||||
/>
|
||||
|
||||
{/* Custom Env Vars */}
|
||||
<KeyValueEditor
|
||||
value={customEnvVars}
|
||||
onChange={setCustomEnvVars}
|
||||
/>
|
||||
|
||||
{/* Live Preview Panel */}
|
||||
<PromptPreview
|
||||
format={promptFormat}
|
||||
theme={effectiveTheme}
|
||||
gitBranch={showGitBranch ? 'main' : null}
|
||||
gitDirty={showGitStatus}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Preview Component**:
|
||||
Shows example prompt like: `[user@host] ~/projects/automaker (main*) $`
|
||||
Updates instantly when theme or format changes.
|
||||
|
||||
### Step 6: Theme Change Hook
|
||||
|
||||
**File**: `apps/server/src/routes/settings.ts`
|
||||
|
||||
**Hook into theme update endpoint**:
|
||||
|
||||
```typescript
|
||||
// After updating theme in settings
|
||||
if (oldTheme !== newTheme) {
|
||||
// Regenerate RC files for all projects with terminal config enabled
|
||||
const projects = settings.projects;
|
||||
for (const project of projects) {
|
||||
const projectSettings = await settingsService.getProjectSettings(project.path);
|
||||
if (projectSettings.terminalConfig?.enabled !== false) {
|
||||
await terminalService.onThemeChange(project.path, newTheme);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Shell Configuration Strategy
|
||||
|
||||
### Bash (via BASH_ENV)
|
||||
|
||||
- Set `BASH_ENV=/path/to/.automaker/terminal/bashrc.sh`
|
||||
- BASH_ENV is loaded for all shells (interactive and non-interactive)
|
||||
- User's ~/.bashrc is sourced first within our bashrc.sh
|
||||
- No need for `--rcfile` flag (which would skip ~/.bashrc)
|
||||
|
||||
### Zsh (via ZDOTDIR)
|
||||
|
||||
- Set `ZDOTDIR=/path/to/.automaker/terminal/`
|
||||
- Create `.zshrc` symlink: `zshrc.zsh`
|
||||
- User's ~/.zshrc is sourced within our zshrc.zsh
|
||||
- Zsh's canonical configuration directory mechanism
|
||||
|
||||
### Sh (via ENV)
|
||||
|
||||
- Set `ENV=/path/to/.automaker/terminal/common.sh`
|
||||
- POSIX shell standard environment variable
|
||||
- Minimal prompt (POSIX sh doesn't support advanced prompts)
|
||||
|
||||
## Prompt Formats
|
||||
|
||||
### 1. Standard
|
||||
|
||||
```
|
||||
[user@host] ~/path/to/project (main*) $
|
||||
```
|
||||
|
||||
### 2. Minimal
|
||||
|
||||
```
|
||||
~/project (main*) $
|
||||
```
|
||||
|
||||
### 3. Powerline (Unicode box-drawing)
|
||||
|
||||
```
|
||||
┌─[user@host]─[~/path]─[main*]
|
||||
└─$
|
||||
```
|
||||
|
||||
### 4. Starship-Inspired
|
||||
|
||||
```
|
||||
user@host in ~/path on main*
|
||||
❯
|
||||
```
|
||||
|
||||
## Theme Synchronization
|
||||
|
||||
### On Initial Enable
|
||||
|
||||
1. User toggles "Enable Custom Terminal Configs"
|
||||
2. Show confirmation dialog explaining what will happen
|
||||
3. Generate RC files for current theme
|
||||
4. Set `rcFileVersion: 11` in settings
|
||||
|
||||
### On Theme Change
|
||||
|
||||
1. User changes app theme in settings
|
||||
2. Settings API detects theme change
|
||||
3. Call `terminalService.onThemeChange()` for each project
|
||||
4. Regenerate theme color files (`.automaker/terminal/themes/`)
|
||||
5. Existing terminals keep old theme (expected behavior)
|
||||
6. New terminals use new theme
|
||||
|
||||
### On Disable
|
||||
|
||||
1. User toggles off "Enable Custom Terminal Configs"
|
||||
2. Delete `.automaker/terminal/` directory
|
||||
3. New terminals spawn without custom env vars
|
||||
4. Existing terminals continue with current config until restarted
|
||||
|
||||
## Critical Files
|
||||
|
||||
### Files to Modify
|
||||
|
||||
1. `/home/dhanush/Projects/automaker/apps/server/src/services/terminal-service.ts` - Add env var injection logic at line ~335-344
|
||||
2. `/home/dhanush/Projects/automaker/libs/types/src/settings.ts` - Add terminalConfig to GlobalSettings (~line 842) and ProjectSettings
|
||||
3. `/home/dhanush/Projects/automaker/apps/server/src/routes/settings.ts` - Add theme change hook
|
||||
|
||||
### Files to Create
|
||||
|
||||
1. `/home/dhanush/Projects/automaker/libs/platform/src/rc-generator.ts` - RC file generation logic
|
||||
2. `/home/dhanush/Projects/automaker/libs/platform/src/rc-file-manager.ts` - File I/O and path resolution
|
||||
3. `/home/dhanush/Projects/automaker/apps/ui/src/components/views/settings-view/terminal/terminal-config-section.tsx` - Settings UI
|
||||
4. `/home/dhanush/Projects/automaker/apps/ui/src/components/views/settings-view/terminal/prompt-preview.tsx` - Live preview component
|
||||
|
||||
### Files to Read
|
||||
|
||||
1. `/home/dhanush/Projects/automaker/apps/ui/src/config/terminal-themes.ts` - Source of theme hex colors for ANSI mapping
|
||||
|
||||
## Testing Approach
|
||||
|
||||
### Unit Tests
|
||||
|
||||
- `rc-generator.test.ts`: Test template generation for all 40 themes
|
||||
- `rc-file-manager.test.ts`: Test file I/O and version checking
|
||||
- `terminal-service.test.ts`: Test env var injection with mocked PTY spawn
|
||||
|
||||
### E2E Tests
|
||||
|
||||
- Enable custom configs in settings
|
||||
- Change theme and verify new terminals use new colors
|
||||
- Add custom aliases and verify they work in terminal
|
||||
- Test all 4 prompt formats
|
||||
- Test disable flow (files removed, terminals work normally)
|
||||
|
||||
### Manual Testing Checklist
|
||||
|
||||
- [ ] Test on macOS with zsh
|
||||
- [ ] Test on Linux with bash
|
||||
- [ ] Test all 40 themes have correct colors
|
||||
- [ ] Test git prompt in repo vs non-repo directories
|
||||
- [ ] Test custom aliases execution
|
||||
- [ ] Test custom env vars available
|
||||
- [ ] Test project-specific overrides
|
||||
- [ ] Test disable/re-enable flow
|
||||
|
||||
## Verification
|
||||
|
||||
### End-to-End Test
|
||||
|
||||
1. Enable custom terminal configs in settings
|
||||
2. Set prompt format to "powerline"
|
||||
3. Add custom alias: `alias gs='git status'`
|
||||
4. Change theme to "dracula"
|
||||
5. Open new terminal
|
||||
6. Verify:
|
||||
- Prompt uses powerline format with theme colors
|
||||
- Git branch shows if in repo
|
||||
- `gs` alias works
|
||||
- User's ~/.bashrc still loaded (test with known alias from user's file)
|
||||
7. Change theme to "nord"
|
||||
8. Open new terminal
|
||||
9. Verify prompt colors changed to match nord theme
|
||||
10. Disable custom configs
|
||||
11. Verify `.automaker/terminal/` deleted
|
||||
12. Open new terminal
|
||||
13. Verify standard prompt without custom config
|
||||
|
||||
### Success Criteria
|
||||
|
||||
- ✅ Feature can be enabled/disabled in settings
|
||||
- ✅ RC files generated in `.automaker/terminal/`
|
||||
- ✅ Prompt colors match theme (all 40 themes)
|
||||
- ✅ Git branch/status shown in prompt
|
||||
- ✅ Custom aliases work
|
||||
- ✅ Custom env vars available
|
||||
- ✅ User's original ~/.bashrc or ~/.zshrc still loads
|
||||
- ✅ Theme changes regenerate color files
|
||||
- ✅ Works on Mac (zsh) and Linux (bash)
|
||||
- ✅ No breaking changes to existing terminal functionality
|
||||
|
||||
## Security & Safety
|
||||
|
||||
### File Permissions
|
||||
|
||||
- RC files: 0644 (user read/write, others read)
|
||||
- Directory: 0755 (user rwx, others rx)
|
||||
- No secrets in RC files
|
||||
|
||||
### Input Sanitization
|
||||
|
||||
- Escape special characters in custom aliases
|
||||
- Validate env var names (alphanumeric + underscore only)
|
||||
- No eval of user-provided code
|
||||
- Shell escaping for all user inputs
|
||||
|
||||
### Backwards Compatibility
|
||||
|
||||
- Feature disabled by default
|
||||
- Existing terminals unaffected when disabled
|
||||
- User's original RC files always sourced first
|
||||
- Easy rollback (just disable and delete files)
|
||||
|
||||
## Branch Creation
|
||||
|
||||
Per PR workflow in DEVELOPMENT_WORKFLOW.md:
|
||||
|
||||
1. Create feature branch: `git checkout -b feature/custom-terminal-configs`
|
||||
2. Implement changes following this plan
|
||||
3. Test thoroughly
|
||||
4. Merge upstream RC before shipping: `git merge upstream/v0.14.0rc --no-edit`
|
||||
5. Push to origin: `git push -u origin feature/custom-terminal-configs`
|
||||
6. Create PR targeting `main` branch
|
||||
|
||||
## Documentation
|
||||
|
||||
After implementation, create comprehensive documentation at:
|
||||
`/home/dhanush/Projects/automaker/docs/terminal-custom-configs.md`
|
||||
|
||||
**Documentation should cover**:
|
||||
|
||||
- Feature overview and benefits
|
||||
- How to enable custom terminal configs
|
||||
- Prompt format options with examples
|
||||
- Custom aliases and env vars
|
||||
- Theme synchronization behavior
|
||||
- Troubleshooting common issues
|
||||
- How to disable the feature
|
||||
- Technical details for contributors
|
||||
|
||||
## Timeline Estimate
|
||||
|
||||
- Week 1: Core infrastructure (RC generator, file manager, settings schema)
|
||||
- Week 2: Terminal service integration, theme sync
|
||||
- Week 3: Settings UI, preview component
|
||||
- Week 4: Testing, documentation, polish
|
||||
|
||||
Total: ~4 weeks for complete implementation
|
||||
@@ -30,15 +30,15 @@ const model2 = resolveModelString('haiku');
|
||||
// Returns: 'claude-haiku-4-5'
|
||||
|
||||
const model3 = resolveModelString('opus');
|
||||
// Returns: 'claude-opus-4-5-20251101'
|
||||
// Returns: 'claude-opus-4-6'
|
||||
|
||||
// Use with custom default
|
||||
const model4 = resolveModelString(undefined, 'claude-sonnet-4-20250514');
|
||||
// Returns: 'claude-sonnet-4-20250514' (default)
|
||||
|
||||
// Direct model ID passthrough
|
||||
const model5 = resolveModelString('claude-opus-4-5-20251101');
|
||||
// Returns: 'claude-opus-4-5-20251101' (unchanged)
|
||||
const model5 = resolveModelString('claude-opus-4-6');
|
||||
// Returns: 'claude-opus-4-6' (unchanged)
|
||||
```
|
||||
|
||||
### Get Effective Model
|
||||
@@ -72,7 +72,7 @@ console.log(DEFAULT_MODELS.chat); // 'claude-sonnet-4-20250514'
|
||||
// Model alias mappings
|
||||
console.log(CLAUDE_MODEL_MAP.haiku); // 'claude-haiku-4-5'
|
||||
console.log(CLAUDE_MODEL_MAP.sonnet); // 'claude-sonnet-4-20250514'
|
||||
console.log(CLAUDE_MODEL_MAP.opus); // 'claude-opus-4-5-20251101'
|
||||
console.log(CLAUDE_MODEL_MAP.opus); // 'claude-opus-4-6'
|
||||
```
|
||||
|
||||
## Usage Example
|
||||
@@ -103,7 +103,7 @@ const feature: Feature = {
|
||||
};
|
||||
|
||||
prepareFeatureExecution(feature);
|
||||
// Output: Executing feature with model: claude-opus-4-5-20251101
|
||||
// Output: Executing feature with model: claude-opus-4-6
|
||||
```
|
||||
|
||||
## Supported Models
|
||||
@@ -112,7 +112,7 @@ prepareFeatureExecution(feature);
|
||||
|
||||
- `haiku` → `claude-haiku-4-5`
|
||||
- `sonnet` → `claude-sonnet-4-20250514`
|
||||
- `opus` → `claude-opus-4-5-20251101`
|
||||
- `opus` → `claude-opus-4-6`
|
||||
|
||||
### Model Selection Guide
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user