diff --git a/.gitignore b/.gitignore
index 7d6c7b0e..d7739863 100644
--- a/.gitignore
+++ b/.gitignore
@@ -95,3 +95,5 @@ data/.api-key
data/credentials.json
data/
.codex/
+.mcp.json
+.planning
\ No newline at end of file
diff --git a/CLAUDE.md b/CLAUDE.md
index 128cd8d7..84dd1fbb 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -161,7 +161,7 @@ Use `resolveModelString()` from `@automaker/model-resolver` to convert model ali
- `haiku` → `claude-haiku-4-5`
- `sonnet` → `claude-sonnet-4-20250514`
-- `opus` → `claude-opus-4-5-20251101`
+- `opus` → `claude-opus-4-6`
## Environment Variables
diff --git a/LICENSE b/LICENSE
index c7a1fe44..e388c0a7 100644
--- a/LICENSE
+++ b/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.
diff --git a/README.md b/README.md
index 95beefe1..98f8683e 100644
--- a/README.md
+++ b/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.
+
Table of Contents
@@ -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:
@@ -680,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.
diff --git a/apps/server/package.json b/apps/server/package.json
index c9015aea..ed005c54 100644
--- a/apps/server/package.json
+++ b/apps/server/package.json
@@ -24,7 +24,7 @@
"test:unit": "vitest run tests/unit"
},
"dependencies": {
- "@anthropic-ai/claude-agent-sdk": "0.1.76",
+ "@anthropic-ai/claude-agent-sdk": "0.2.32",
"@automaker/dependency-resolver": "1.0.0",
"@automaker/git-utils": "1.0.0",
"@automaker/model-resolver": "1.0.0",
@@ -34,7 +34,7 @@
"@automaker/utils": "1.0.0",
"@github/copilot-sdk": "^0.1.16",
"@modelcontextprotocol/sdk": "1.25.2",
- "@openai/codex-sdk": "^0.77.0",
+ "@openai/codex-sdk": "^0.98.0",
"cookie-parser": "1.4.7",
"cors": "2.8.5",
"dotenv": "17.2.3",
diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts
index 4bd496bc..4f49d117 100644
--- a/apps/server/src/index.ts
+++ b/apps/server/src/index.ts
@@ -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> | 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,27 @@ 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) => `║ ${p.substring(0, BOX_CONTENT_WIDTH - 4).padEnd(BOX_CONTENT_WIDTH - 4)} ║`).join('\n')}`;
+ }
+ }
+
logger.warn(`
╔═════════════════════════════════════════════════════════════════════╗
║ ${wHeader}║
@@ -169,7 +226,7 @@ const BOX_CONTENT_WIDTH = 67;
║ ${w3}║
║ ${w4}║
║ ${w5}║
-║ ${w6}║
+║ ${w6}║${pathsCheckedInfo}
║ ║
╚═════════════════════════════════════════════════════════════════════╝
`);
@@ -390,7 +447,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
diff --git a/apps/server/src/lib/sdk-options.ts b/apps/server/src/lib/sdk-options.ts
index cc1df2f5..674350a5 100644
--- a/apps/server/src/lib/sdk-options.ts
+++ b/apps/server/src/lib/sdk-options.ts
@@ -253,11 +253,27 @@ function buildMcpOptions(config: CreateSdkOptionsConfig): McpOptions {
/**
* Build thinking options for SDK configuration.
* Converts ThinkingLevel to maxThinkingTokens for the Claude SDK.
+ * For adaptive thinking (Opus 4.6), omits maxThinkingTokens to let the model
+ * decide its own reasoning depth.
*
* @param thinkingLevel - The thinking level to convert
- * @returns Object with maxThinkingTokens if thinking is enabled
+ * @returns Object with maxThinkingTokens if thinking is enabled with a budget
*/
function buildThinkingOptions(thinkingLevel?: ThinkingLevel): Partial {
+ if (!thinkingLevel || thinkingLevel === 'none') {
+ return {};
+ }
+
+ // Adaptive thinking (Opus 4.6): don't set maxThinkingTokens
+ // The model will use adaptive thinking by default
+ if (thinkingLevel === 'adaptive') {
+ logger.debug(
+ `buildThinkingOptions: thinkingLevel="adaptive" -> no maxThinkingTokens (model decides)`
+ );
+ return {};
+ }
+
+ // Manual budget-based thinking for Haiku/Sonnet
const maxThinkingTokens = getThinkingTokenBudget(thinkingLevel);
logger.debug(
`buildThinkingOptions: thinkingLevel="${thinkingLevel}" -> maxThinkingTokens=${maxThinkingTokens}`
diff --git a/apps/server/src/lib/terminal-themes-data.ts b/apps/server/src/lib/terminal-themes-data.ts
new file mode 100644
index 00000000..854bf1a8
--- /dev/null
+++ b/apps/server/src/lib/terminal-themes-data.ts
@@ -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 {
+ return terminalThemeColors;
+}
+
+export default terminalThemeColors;
diff --git a/apps/server/src/providers/claude-provider.ts b/apps/server/src/providers/claude-provider.ts
index cfb59093..78a0a0c7 100644
--- a/apps/server/src/providers/claude-provider.ts
+++ b/apps/server/src/providers/claude-provider.ts
@@ -219,8 +219,11 @@ export class ClaudeProvider extends BaseProvider {
// claudeCompatibleProvider takes precedence over claudeApiProfile
const providerConfig = claudeCompatibleProvider || claudeApiProfile;
- // Convert thinking level to token budget
- const maxThinkingTokens = getThinkingTokenBudget(thinkingLevel);
+ // Build thinking configuration
+ // Adaptive thinking (Opus 4.6): don't set maxThinkingTokens, model uses adaptive by default
+ // Manual thinking (Haiku/Sonnet): use budget_tokens
+ const maxThinkingTokens =
+ thinkingLevel === 'adaptive' ? undefined : getThinkingTokenBudget(thinkingLevel);
// Build Claude SDK options
const sdkOptions: Options = {
@@ -349,13 +352,13 @@ export class ClaudeProvider extends BaseProvider {
getAvailableModels(): ModelDefinition[] {
const models = [
{
- id: 'claude-opus-4-5-20251101',
- name: 'Claude Opus 4.5',
- modelString: 'claude-opus-4-5-20251101',
+ id: 'claude-opus-4-6',
+ name: 'Claude Opus 4.6',
+ modelString: 'claude-opus-4-6',
provider: 'anthropic',
- description: 'Most capable Claude model',
+ description: 'Most capable Claude model with adaptive thinking',
contextWindow: 200000,
- maxOutputTokens: 16000,
+ maxOutputTokens: 128000,
supportsVision: true,
supportsTools: true,
tier: 'premium' as const,
diff --git a/apps/server/src/providers/codex-models.ts b/apps/server/src/providers/codex-models.ts
index 141d5355..7840888b 100644
--- a/apps/server/src/providers/codex-models.ts
+++ b/apps/server/src/providers/codex-models.ts
@@ -19,12 +19,11 @@ const MAX_OUTPUT_16K = 16000;
export const CODEX_MODELS: ModelDefinition[] = [
// ========== Recommended Codex Models ==========
{
- id: CODEX_MODEL_MAP.gpt52Codex,
- name: 'GPT-5.2-Codex',
- modelString: CODEX_MODEL_MAP.gpt52Codex,
+ id: CODEX_MODEL_MAP.gpt53Codex,
+ name: 'GPT-5.3-Codex',
+ modelString: CODEX_MODEL_MAP.gpt53Codex,
provider: 'openai',
- description:
- 'Most advanced agentic coding model for complex software engineering (default for ChatGPT users).',
+ description: 'Latest frontier agentic coding model.',
contextWindow: CONTEXT_WINDOW_256K,
maxOutputTokens: MAX_OUTPUT_32K,
supportsVision: true,
@@ -33,12 +32,25 @@ export const CODEX_MODELS: ModelDefinition[] = [
default: true,
hasReasoning: true,
},
+ {
+ id: CODEX_MODEL_MAP.gpt52Codex,
+ name: 'GPT-5.2-Codex',
+ modelString: CODEX_MODEL_MAP.gpt52Codex,
+ provider: 'openai',
+ description: 'Frontier agentic coding model.',
+ contextWindow: CONTEXT_WINDOW_256K,
+ maxOutputTokens: MAX_OUTPUT_32K,
+ supportsVision: true,
+ supportsTools: true,
+ tier: 'premium' as const,
+ hasReasoning: true,
+ },
{
id: CODEX_MODEL_MAP.gpt51CodexMax,
name: 'GPT-5.1-Codex-Max',
modelString: CODEX_MODEL_MAP.gpt51CodexMax,
provider: 'openai',
- description: 'Optimized for long-horizon, agentic coding tasks in Codex.',
+ description: 'Codex-optimized flagship for deep and fast reasoning.',
contextWindow: CONTEXT_WINDOW_256K,
maxOutputTokens: MAX_OUTPUT_32K,
supportsVision: true,
@@ -51,7 +63,7 @@ export const CODEX_MODELS: ModelDefinition[] = [
name: 'GPT-5.1-Codex-Mini',
modelString: CODEX_MODEL_MAP.gpt51CodexMini,
provider: 'openai',
- description: 'Smaller, more cost-effective version for faster workflows.',
+ description: 'Optimized for codex. Cheaper, faster, but less capable.',
contextWindow: CONTEXT_WINDOW_128K,
maxOutputTokens: MAX_OUTPUT_16K,
supportsVision: true,
@@ -66,7 +78,7 @@ export const CODEX_MODELS: ModelDefinition[] = [
name: 'GPT-5.2',
modelString: CODEX_MODEL_MAP.gpt52,
provider: 'openai',
- description: 'Best general agentic model for tasks across industries and domains.',
+ description: 'Latest frontier model with improvements across knowledge, reasoning and coding.',
contextWindow: CONTEXT_WINDOW_256K,
maxOutputTokens: MAX_OUTPUT_32K,
supportsVision: true,
diff --git a/apps/server/src/providers/cursor-provider.ts b/apps/server/src/providers/cursor-provider.ts
index 8e62ce04..a2e813c0 100644
--- a/apps/server/src/providers/cursor-provider.ts
+++ b/apps/server/src/providers/cursor-provider.ts
@@ -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
}
}
}
diff --git a/apps/server/src/providers/provider-factory.ts b/apps/server/src/providers/provider-factory.ts
index 1e91760f..a6dff69e 100644
--- a/apps/server/src/providers/provider-factory.ts
+++ b/apps/server/src/providers/provider-factory.ts
@@ -103,7 +103,7 @@ export class ProviderFactory {
/**
* Get the appropriate provider for a given model ID
*
- * @param modelId Model identifier (e.g., "claude-opus-4-5-20251101", "cursor-gpt-4o", "cursor-auto")
+ * @param modelId Model identifier (e.g., "claude-opus-4-6", "cursor-gpt-4o", "cursor-auto")
* @param options Optional settings
* @param options.throwOnDisconnected Throw error if provider is disconnected (default: true)
* @returns Provider instance for the model
diff --git a/apps/server/src/routes/enhance-prompt/routes/enhance.ts b/apps/server/src/routes/enhance-prompt/routes/enhance.ts
index 9045a18d..dbdde007 100644
--- a/apps/server/src/routes/enhance-prompt/routes/enhance.ts
+++ b/apps/server/src/routes/enhance-prompt/routes/enhance.ts
@@ -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 {
+ 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
@@ -156,7 +229,7 @@ export function createEnhanceHandler(
// 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}`,
+ prompt: [systemPrompt, projectContext, userPrompt].filter(Boolean).join('\n\n'),
model: resolvedModel,
cwd: process.cwd(), // Enhancement doesn't need a specific working directory
maxTurns: 1,
diff --git a/apps/server/src/routes/fs/routes/save-image.ts b/apps/server/src/routes/fs/routes/save-image.ts
index c8cfdda7..695a8ded 100644
--- a/apps/server/src/routes/fs/routes/save-image.ts
+++ b/apps/server/src/routes/fs/routes/save-image.ts
@@ -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 => {
@@ -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);
diff --git a/apps/server/src/routes/settings/routes/update-global.ts b/apps/server/src/routes/settings/routes/update-global.ts
index b45e9965..817b5c1d 100644
--- a/apps/server/src/routes/settings/routes/update-global.ts
+++ b/apps/server/src/routes/settings/routes/update-global.ts
@@ -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];
diff --git a/apps/server/src/routes/setup/routes/verify-claude-auth.ts b/apps/server/src/routes/setup/routes/verify-claude-auth.ts
index df04d462..405ef9a6 100644
--- a/apps/server/src/routes/setup/routes/verify-claude-auth.ts
+++ b/apps/server/src/routes/setup/routes/verify-claude-auth.ts
@@ -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,27 @@ export function createVerifyClaudeAuthHandler() {
authMethod,
});
+ // Determine specific auth type for success messages
+ let authType: 'oauth' | 'api_key' | 'cli' | undefined;
+ if (authenticated) {
+ if (authMethod === 'api_key') {
+ authType = 'api_key';
+ } else if (authMethod === 'cli') {
+ // Check if CLI auth is via OAuth (Claude Code subscription) or generic CLI
+ 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) {
diff --git a/apps/server/src/routes/worktree/routes/switch-branch.ts b/apps/server/src/routes/worktree/routes/switch-branch.ts
index d087341b..63be752b 100644
--- a/apps/server/src/routes/worktree/routes/switch-branch.ts
+++ b/apps/server/src/routes/worktree/routes/switch-branch.ts
@@ -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 {
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 {
async function getChangesSummary(cwd: string): Promise {
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`;
diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts
index 36ae4a2e..ffb87591 100644
--- a/apps/server/src/services/auto-mode-service.ts
+++ b/apps/server/src/services/auto-mode-service.ts
@@ -2657,13 +2657,67 @@ Address the follow-up instructions above. Review the previous work and make the
// Load feature for commit message
const feature = await this.loadFeature(projectPath, featureId);
const commitMessage = feature
- ? `feat: ${this.extractTitleFromDescription(
- feature.description
- )}\n\nImplemented by Automaker auto-mode`
- : `feat: Feature ${featureId}`;
+ ? await this.generateCommitMessage(feature, workDir)
+ : `feat: Feature ${featureId}\n\nImplemented by Automaker auto-mode`;
- // Stage and commit
- await execAsync('git add -A', { cwd: workDir });
+ // Determine which files to stage
+ // For feature branches, only stage files changed on this branch to avoid committing unrelated changes
+ let filesToStage: string[] = [];
+
+ try {
+ // Get the current branch
+ const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', {
+ cwd: workDir,
+ });
+ const branch = currentBranch.trim();
+
+ // Get the base branch (usually main/master)
+ const { stdout: baseBranchOutput } = await execAsync(
+ 'git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null || echo "refs/remotes/origin/main"',
+ { cwd: workDir }
+ );
+ const baseBranch = baseBranchOutput.trim().replace('refs/remotes/origin/', '');
+
+ // If we're on a feature branch (not the base branch), only stage files changed on this branch
+ if (branch !== baseBranch && feature?.branchName) {
+ try {
+ // Get files changed on this branch compared to base
+ const { stdout: branchFiles } = await execAsync(
+ `git diff --name-only ${baseBranch}...HEAD`,
+ { cwd: workDir }
+ );
+
+ if (branchFiles.trim()) {
+ filesToStage = branchFiles.trim().split('\n').filter(Boolean);
+ logger.info(`Staging ${filesToStage.length} files changed on branch ${branch}`);
+ }
+ } catch (diffError) {
+ // If diff fails (e.g., base branch doesn't exist), fall back to staging all changes
+ logger.warn(`Could not diff against base branch, staging all changes: ${diffError}`);
+ filesToStage = [];
+ }
+ }
+ } catch (error) {
+ logger.warn(`Could not determine branch-specific files: ${error}`);
+ }
+
+ // Stage files
+ if (filesToStage.length > 0) {
+ // Stage only the specific files changed on this branch
+ for (const file of filesToStage) {
+ try {
+ await execAsync(`git add "${file.replace(/"/g, '\\"')}"`, { cwd: workDir });
+ } catch (error) {
+ logger.warn(`Failed to stage file ${file}: ${error}`);
+ }
+ }
+ } else {
+ // Fallback: stage all changes (original behavior)
+ // This happens for main branch features or when branch detection fails
+ await execAsync('git add -A', { cwd: workDir });
+ }
+
+ // Commit
await execAsync(`git commit -m "${commitMessage.replace(/"/g, '\\"')}"`, {
cwd: workDir,
});
@@ -3664,13 +3718,14 @@ Format your response as a structured markdown document.`;
// Recovery cases:
// 1. Standard pending/ready/backlog statuses
// 2. Features with approved plans that have incomplete tasks (crash recovery)
- // 3. Features stuck in 'in_progress' status (crash recovery)
+ // 3. Features stuck in 'in_progress' or 'interrupted' status (crash recovery)
// 4. Features with 'generating' planSpec status (spec generation was interrupted)
const needsRecovery =
feature.status === 'pending' ||
feature.status === 'ready' ||
feature.status === 'backlog' ||
feature.status === 'in_progress' || // Recover features that were in progress when server crashed
+ feature.status === 'interrupted' || // Recover features explicitly marked interrupted on shutdown
(feature.planSpec?.status === 'approved' &&
(feature.planSpec.tasksCompleted ?? 0) < (feature.planSpec.tasksTotal ?? 0)) ||
feature.planSpec?.status === 'generating'; // Recover interrupted spec generation
@@ -3710,7 +3765,7 @@ Format your response as a structured markdown document.`;
const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
logger.info(
- `[loadPendingFeatures] Found ${allFeatures.length} total features, ${pendingFeatures.length} candidates (pending/ready/backlog/in_progress/approved_with_pending_tasks/generating) for ${worktreeDesc}`
+ `[loadPendingFeatures] Found ${allFeatures.length} total features, ${pendingFeatures.length} candidates (pending/ready/backlog/in_progress/interrupted/approved_with_pending_tasks/generating) for ${worktreeDesc}`
);
if (pendingFeatures.length === 0) {
@@ -3840,6 +3895,58 @@ Format your response as a structured markdown document.`;
return firstLine.substring(0, 57) + '...';
}
+ /**
+ * Generate a comprehensive commit message for a feature
+ * Includes title, description summary, and file statistics
+ */
+ private async generateCommitMessage(feature: Feature, workDir: string): Promise {
+ const title = this.extractTitleFromDescription(feature.description);
+
+ // Extract description summary (first 3-5 lines, up to 300 chars)
+ let descriptionSummary = '';
+ if (feature.description && feature.description.trim()) {
+ const lines = feature.description.split('\n').filter((l) => l.trim());
+ const summaryLines = lines.slice(0, 5); // First 5 non-empty lines
+ descriptionSummary = summaryLines.join('\n');
+
+ // Limit to 300 characters
+ if (descriptionSummary.length > 300) {
+ descriptionSummary = descriptionSummary.substring(0, 297) + '...';
+ }
+ }
+
+ // Get file statistics to add context
+ let fileStats = '';
+ try {
+ const { stdout: diffStat } = await execAsync('git diff --cached --stat', { cwd: workDir });
+ if (diffStat.trim()) {
+ // Extract just the summary line (last line with file count)
+ const statLines = diffStat.trim().split('\n');
+ const summaryLine = statLines[statLines.length - 1];
+ if (summaryLine && summaryLine.includes('file')) {
+ fileStats = `\n${summaryLine.trim()}`;
+ }
+ }
+ } catch {
+ // Ignore errors getting stats
+ }
+
+ // Build commit message
+ let message = `feat: ${title}`;
+
+ if (descriptionSummary && descriptionSummary !== title) {
+ message += `\n\n${descriptionSummary}`;
+ }
+
+ if (fileStats) {
+ message += fileStats;
+ }
+
+ message += '\n\nImplemented by Automaker auto-mode';
+
+ return message;
+ }
+
/**
* Get the planning prompt prefix based on feature's planning mode
*/
@@ -5430,9 +5537,10 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
continue;
}
- // Check if feature was interrupted (in_progress or pipeline_*)
+ // Check if feature was interrupted (in_progress/interrupted or pipeline_*)
if (
feature.status === 'in_progress' ||
+ feature.status === 'interrupted' ||
(feature.status && feature.status.startsWith('pipeline_'))
) {
// Check if context (agent-output.md) exists
diff --git a/apps/server/src/services/dev-server-service.ts b/apps/server/src/services/dev-server-service.ts
index 74ed8220..d81e539c 100644
--- a/apps/server/src/services/dev-server-service.ts
+++ b/apps/server/src/services/dev-server-service.ts
@@ -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
diff --git a/apps/server/src/services/terminal-service.ts b/apps/server/src/services/terminal-service.ts
index f83aaede..167ab348 100644
--- a/apps/server/src/services/terminal-service.ts
+++ b/apps/server/src/services/terminal-service.ts
@@ -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 = {};
+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 | 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).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 = {};
+ 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 = {
...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 {
+ 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;
}
diff --git a/apps/server/tests/unit/lib/model-resolver.test.ts b/apps/server/tests/unit/lib/model-resolver.test.ts
index c1bff78d..65e3115d 100644
--- a/apps/server/tests/unit/lib/model-resolver.test.ts
+++ b/apps/server/tests/unit/lib/model-resolver.test.ts
@@ -35,7 +35,7 @@ describe('model-resolver.ts', () => {
it("should resolve 'opus' alias to full model string", () => {
const result = resolveModelString('opus');
- expect(result).toBe('claude-opus-4-5-20251101');
+ expect(result).toBe('claude-opus-4-6');
expect(consoleSpy.log).toHaveBeenCalledWith(
expect.stringContaining('Migrated legacy ID: "opus" -> "claude-opus"')
);
@@ -117,7 +117,7 @@ describe('model-resolver.ts', () => {
describe('getEffectiveModel', () => {
it('should prioritize explicit model over session and default', () => {
const result = getEffectiveModel('opus', 'haiku', 'gpt-5.2');
- expect(result).toBe('claude-opus-4-5-20251101');
+ expect(result).toBe('claude-opus-4-6');
});
it('should use session model when explicit is not provided', () => {
diff --git a/apps/server/tests/unit/lib/sdk-options.test.ts b/apps/server/tests/unit/lib/sdk-options.test.ts
index 029cd8fa..69d69794 100644
--- a/apps/server/tests/unit/lib/sdk-options.test.ts
+++ b/apps/server/tests/unit/lib/sdk-options.test.ts
@@ -491,5 +491,29 @@ describe('sdk-options.ts', () => {
expect(options.maxThinkingTokens).toBeUndefined();
});
});
+
+ describe('adaptive thinking for Opus 4.6', () => {
+ it('should not set maxThinkingTokens for adaptive thinking (model decides)', async () => {
+ const { createAutoModeOptions } = await import('@/lib/sdk-options.js');
+
+ const options = createAutoModeOptions({
+ cwd: '/test/path',
+ thinkingLevel: 'adaptive',
+ });
+
+ expect(options.maxThinkingTokens).toBeUndefined();
+ });
+
+ it('should not include maxThinkingTokens when thinkingLevel is "none"', async () => {
+ const { createAutoModeOptions } = await import('@/lib/sdk-options.js');
+
+ const options = createAutoModeOptions({
+ cwd: '/test/path',
+ thinkingLevel: 'none',
+ });
+
+ expect(options.maxThinkingTokens).toBeUndefined();
+ });
+ });
});
});
diff --git a/apps/server/tests/unit/providers/claude-provider.test.ts b/apps/server/tests/unit/providers/claude-provider.test.ts
index c3f83f8f..7df211ef 100644
--- a/apps/server/tests/unit/providers/claude-provider.test.ts
+++ b/apps/server/tests/unit/providers/claude-provider.test.ts
@@ -39,7 +39,7 @@ describe('claude-provider.ts', () => {
const generator = provider.executeQuery({
prompt: 'Hello',
- model: 'claude-opus-4-5-20251101',
+ model: 'claude-opus-4-6',
cwd: '/test',
});
@@ -59,7 +59,7 @@ describe('claude-provider.ts', () => {
const generator = provider.executeQuery({
prompt: 'Test prompt',
- model: 'claude-opus-4-5-20251101',
+ model: 'claude-opus-4-6',
cwd: '/test/dir',
systemPrompt: 'You are helpful',
maxTurns: 10,
@@ -71,7 +71,7 @@ describe('claude-provider.ts', () => {
expect(sdk.query).toHaveBeenCalledWith({
prompt: 'Test prompt',
options: expect.objectContaining({
- model: 'claude-opus-4-5-20251101',
+ model: 'claude-opus-4-6',
systemPrompt: 'You are helpful',
maxTurns: 10,
cwd: '/test/dir',
@@ -91,7 +91,7 @@ describe('claude-provider.ts', () => {
const generator = provider.executeQuery({
prompt: 'Test',
- model: 'claude-opus-4-5-20251101',
+ model: 'claude-opus-4-6',
cwd: '/test',
});
@@ -116,7 +116,7 @@ describe('claude-provider.ts', () => {
const generator = provider.executeQuery({
prompt: 'Test',
- model: 'claude-opus-4-5-20251101',
+ model: 'claude-opus-4-6',
cwd: '/test',
abortController,
});
@@ -145,7 +145,7 @@ describe('claude-provider.ts', () => {
const generator = provider.executeQuery({
prompt: 'Current message',
- model: 'claude-opus-4-5-20251101',
+ model: 'claude-opus-4-6',
cwd: '/test',
conversationHistory,
sdkSessionId: 'test-session-id',
@@ -176,7 +176,7 @@ describe('claude-provider.ts', () => {
const generator = provider.executeQuery({
prompt: arrayPrompt as any,
- model: 'claude-opus-4-5-20251101',
+ model: 'claude-opus-4-6',
cwd: '/test',
});
@@ -196,7 +196,7 @@ describe('claude-provider.ts', () => {
const generator = provider.executeQuery({
prompt: 'Test',
- model: 'claude-opus-4-5-20251101',
+ model: 'claude-opus-4-6',
cwd: '/test',
});
@@ -222,7 +222,7 @@ describe('claude-provider.ts', () => {
const generator = provider.executeQuery({
prompt: 'Test',
- model: 'claude-opus-4-5-20251101',
+ model: 'claude-opus-4-6',
cwd: '/test',
});
@@ -286,7 +286,7 @@ describe('claude-provider.ts', () => {
const generator = provider.executeQuery({
prompt: 'Test',
- model: 'claude-opus-4-5-20251101',
+ model: 'claude-opus-4-6',
cwd: '/test',
});
@@ -313,7 +313,7 @@ describe('claude-provider.ts', () => {
const generator = provider.executeQuery({
prompt: 'Test',
- model: 'claude-opus-4-5-20251101',
+ model: 'claude-opus-4-6',
cwd: '/test',
});
@@ -341,7 +341,7 @@ describe('claude-provider.ts', () => {
const generator = provider.executeQuery({
prompt: 'Test',
- model: 'claude-opus-4-5-20251101',
+ model: 'claude-opus-4-6',
cwd: '/test',
});
@@ -366,12 +366,12 @@ describe('claude-provider.ts', () => {
expect(models).toHaveLength(4);
});
- it('should include Claude Opus 4.5', () => {
+ it('should include Claude Opus 4.6', () => {
const models = provider.getAvailableModels();
- const opus = models.find((m) => m.id === 'claude-opus-4-5-20251101');
+ const opus = models.find((m) => m.id === 'claude-opus-4-6');
expect(opus).toBeDefined();
- expect(opus?.name).toBe('Claude Opus 4.5');
+ expect(opus?.name).toBe('Claude Opus 4.6');
expect(opus?.provider).toBe('anthropic');
});
@@ -400,7 +400,7 @@ describe('claude-provider.ts', () => {
it('should mark Opus as default', () => {
const models = provider.getAvailableModels();
- const opus = models.find((m) => m.id === 'claude-opus-4-5-20251101');
+ const opus = models.find((m) => m.id === 'claude-opus-4-6');
expect(opus?.default).toBe(true);
});
diff --git a/apps/server/tests/unit/providers/provider-factory.test.ts b/apps/server/tests/unit/providers/provider-factory.test.ts
index fbf01e90..b9aef928 100644
--- a/apps/server/tests/unit/providers/provider-factory.test.ts
+++ b/apps/server/tests/unit/providers/provider-factory.test.ts
@@ -54,8 +54,8 @@ describe('provider-factory.ts', () => {
describe('getProviderForModel', () => {
describe('Claude models (claude-* prefix)', () => {
- it('should return ClaudeProvider for claude-opus-4-5-20251101', () => {
- const provider = ProviderFactory.getProviderForModel('claude-opus-4-5-20251101');
+ it('should return ClaudeProvider for claude-opus-4-6', () => {
+ const provider = ProviderFactory.getProviderForModel('claude-opus-4-6');
expect(provider).toBeInstanceOf(ClaudeProvider);
});
@@ -70,7 +70,7 @@ describe('provider-factory.ts', () => {
});
it('should be case-insensitive for claude models', () => {
- const provider = ProviderFactory.getProviderForModel('CLAUDE-OPUS-4-5-20251101');
+ const provider = ProviderFactory.getProviderForModel('CLAUDE-OPUS-4-6');
expect(provider).toBeInstanceOf(ClaudeProvider);
});
});
diff --git a/apps/server/tests/unit/routes/worktree/switch-branch.test.ts b/apps/server/tests/unit/routes/worktree/switch-branch.test.ts
new file mode 100644
index 00000000..2cd868c6
--- /dev/null
+++ b/apps/server/tests/unit/routes/worktree/switch-branch.test.ts
@@ -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();
+ return {
+ ...actual,
+ exec: vi.fn(),
+ };
+});
+
+vi.mock('util', async (importOriginal) => {
+ const actual = await importOriginal();
+ 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',
+ });
+ });
+});
diff --git a/apps/server/tests/unit/services/auto-mode-service.test.ts b/apps/server/tests/unit/services/auto-mode-service.test.ts
index 7f3f9af0..a8489033 100644
--- a/apps/server/tests/unit/services/auto-mode-service.test.ts
+++ b/apps/server/tests/unit/services/auto-mode-service.test.ts
@@ -1,6 +1,9 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { AutoModeService } from '@/services/auto-mode-service.js';
import type { Feature } from '@automaker/types';
+import fs from 'fs/promises';
+import os from 'os';
+import path from 'path';
describe('auto-mode-service.ts', () => {
let service: AutoModeService;
@@ -842,4 +845,76 @@ describe('auto-mode-service.ts', () => {
expect(service.isFeatureRunning('feature-3')).toBe(false);
});
});
+
+ describe('interrupted recovery', () => {
+ async function createFeatureFixture(
+ projectPath: string,
+ feature: Partial & Pick
+ ): Promise {
+ const featureDir = path.join(projectPath, '.automaker', 'features', feature.id);
+ await fs.mkdir(featureDir, { recursive: true });
+ await fs.writeFile(
+ path.join(featureDir, 'feature.json'),
+ JSON.stringify(
+ {
+ title: 'Feature',
+ description: 'Feature description',
+ category: 'implementation',
+ status: 'backlog',
+ ...feature,
+ },
+ null,
+ 2
+ )
+ );
+ return featureDir;
+ }
+
+ it('should resume features marked as interrupted after restart', async () => {
+ const projectPath = await fs.mkdtemp(path.join(os.tmpdir(), 'automaker-resume-'));
+ try {
+ const featureDir = await createFeatureFixture(projectPath, {
+ id: 'feature-interrupted',
+ status: 'interrupted',
+ });
+ await fs.writeFile(path.join(featureDir, 'agent-output.md'), 'partial progress');
+ await createFeatureFixture(projectPath, {
+ id: 'feature-complete',
+ status: 'completed',
+ });
+
+ const resumeFeatureMock = vi.fn().mockResolvedValue(undefined);
+ (service as any).resumeFeature = resumeFeatureMock;
+
+ await (service as any).resumeInterruptedFeatures(projectPath);
+
+ expect(resumeFeatureMock).toHaveBeenCalledTimes(1);
+ expect(resumeFeatureMock).toHaveBeenCalledWith(projectPath, 'feature-interrupted', true);
+ } finally {
+ await fs.rm(projectPath, { recursive: true, force: true });
+ }
+ });
+
+ it('should include interrupted features in pending recovery candidates', async () => {
+ const projectPath = await fs.mkdtemp(path.join(os.tmpdir(), 'automaker-pending-'));
+ try {
+ await createFeatureFixture(projectPath, {
+ id: 'feature-interrupted',
+ status: 'interrupted',
+ });
+ await createFeatureFixture(projectPath, {
+ id: 'feature-waiting-approval',
+ status: 'waiting_approval',
+ });
+
+ const pendingFeatures = await (service as any).loadPendingFeatures(projectPath, null);
+ const pendingIds = pendingFeatures.map((feature: Feature) => feature.id);
+
+ expect(pendingIds).toContain('feature-interrupted');
+ expect(pendingIds).not.toContain('feature-waiting-approval');
+ } finally {
+ await fs.rm(projectPath, { recursive: true, force: true });
+ }
+ });
+ });
});
diff --git a/apps/server/tests/unit/services/dev-server-service.test.ts b/apps/server/tests/unit/services/dev-server-service.test.ts
index d390926a..e95259bc 100644
--- a/apps/server/tests/unit/services/dev-server-service.test.ts
+++ b/apps/server/tests/unit/services/dev-server-service.test.ts
@@ -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
diff --git a/apps/ui/docs/AGENT_ARCHITECTURE.md b/apps/ui/docs/AGENT_ARCHITECTURE.md
index 4c9f0d11..f5c374c4 100644
--- a/apps/ui/docs/AGENT_ARCHITECTURE.md
+++ b/apps/ui/docs/AGENT_ARCHITECTURE.md
@@ -199,7 +199,7 @@ The agent is configured with:
```javascript
{
- model: "claude-opus-4-5-20251101",
+ model: "claude-opus-4-6",
maxTurns: 20,
cwd: workingDirectory,
allowedTools: [
diff --git a/apps/ui/src/components/dialogs/sandbox-risk-dialog.tsx b/apps/ui/src/components/dialogs/sandbox-risk-dialog.tsx
index 3a5f6d35..7b597c8c 100644
--- a/apps/ui/src/components/dialogs/sandbox-risk-dialog.tsx
+++ b/apps/ui/src/components/dialogs/sandbox-risk-dialog.tsx
@@ -69,6 +69,29 @@ export function SandboxRiskDialog({ open, onConfirm, onDeny }: SandboxRiskDialog
For safer operation, consider running Automaker in Docker. See the README for
instructions.
+
+
+
+ Already running in Docker? Try these troubleshooting steps:
+
+
+
+ Ensure IS_CONTAINERIZED=true is
+ set in your docker-compose environment
+
+
+ Verify the server container has the environment variable:{' '}
+
+ docker exec automaker-server printenv IS_CONTAINERIZED
+
+
+
Rebuild and restart containers if you recently changed the configuration
+
+ Check the server logs for startup messages:{' '}
+ docker-compose logs server
+
+ 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.
+
+
+
+
+ {/* Enable Toggle */}
+
+
+
+
+ Create theme-synced shell configs in .automaker/terminal/
+
+ How it works: Custom configs are applied to new terminals only.
+ Your ~/.bashrc and ~/.zshrc are still loaded first. Close and reopen terminals to
+ see changes.
+
+
+
+ {/* Custom Prompt Toggle */}
+
+
+
+
+ Override default shell prompt with themed version
+
+ {promptThemePreset?.label ?? 'Oh My Posh theme'} 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.
+
- Your Claude CLI is working correctly.
+ {cliAuthType === 'oauth'
+ ? 'Your Claude Code subscription is active and ready to use.'
+ : 'Your Claude CLI is working correctly.'}
diff --git a/apps/ui/src/hooks/queries/use-features.ts b/apps/ui/src/hooks/queries/use-features.ts
index 334ec3d4..d4a88421 100644
--- a/apps/ui/src/hooks/queries/use-features.ts
+++ b/apps/ui/src/hooks/queries/use-features.ts
@@ -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,
});
diff --git a/apps/ui/src/hooks/queries/use-running-agents.ts b/apps/ui/src/hooks/queries/use-running-agents.ts
index 7aabc1d8..81cbfede 100644
--- a/apps/ui/src/hooks/queries/use-running-agents.ts
+++ b/apps/ui/src/hooks/queries/use-running-agents.ts
@@ -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,
});
diff --git a/apps/ui/src/hooks/queries/use-worktrees.ts b/apps/ui/src/hooks/queries/use-worktrees.ts
index 8012d1cb..60fdcca9 100644
--- a/apps/ui/src/hooks/queries/use-worktrees.ts
+++ b/apps/ui/src/hooks/queries/use-worktrees.ts
@@ -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,
});
diff --git a/apps/ui/src/hooks/use-auto-mode.ts b/apps/ui/src/hooks/use-auto-mode.ts
index 29fe1fe8..cb683417 100644
--- a/apps/ui/src/hooks/use-auto-mode.ts
+++ b/apps/ui/src/hooks/use-auto-mode.ts
@@ -6,10 +6,12 @@ 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';
+const AUTO_MODE_POLLING_INTERVAL = 30000;
/**
* Generate a worktree key for session storage
@@ -140,42 +142,54 @@ export function useAutoMode(worktree?: WorktreeInfo) {
// Check if we can start a new task based on concurrency limit
const canStartNewTask = runningAutoTasks.length < maxConcurrency;
+ const refreshStatus = useCallback(async () => {
+ if (!currentProject) return;
+
+ 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;
+
+ 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);
+ }
+ }
+ } catch (error) {
+ logger.error('Error syncing auto mode state with backend:', error);
+ }
+ }, [branchName, currentProject, isAutoModeRunning, 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 syncWithBackend = async () => {
- try {
- const api = getElectronAPI();
- if (!api?.autoMode?.status) return;
+ const interval = setInterval(() => {
+ if (getGlobalEventsRecent()) return;
+ void refreshStatus();
+ }, AUTO_MODE_POLLING_INTERVAL);
- const result = await api.autoMode.status(currentProject.path, branchName);
- if (result.success && result.isAutoLoopRunning !== undefined) {
- const backendIsRunning = result.isAutoLoopRunning;
-
- 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);
- }
- }
- } catch (error) {
- logger.error('Error syncing auto mode state with backend:', error);
- }
- };
-
- syncWithBackend();
- }, [currentProject, branchName, setAutoModeRunning]);
+ return () => clearInterval(interval);
+ }, [currentProject, refreshStatus]);
// Handle auto mode events - listen globally for all projects/worktrees
useEffect(() => {
@@ -672,5 +686,6 @@ export function useAutoMode(worktree?: WorktreeInfo) {
start,
stop,
stopFeature,
+ refreshStatus,
};
}
diff --git a/apps/ui/src/lib/agent-context-parser.ts b/apps/ui/src/lib/agent-context-parser.ts
index 8313e055..996b397b 100644
--- a/apps/ui/src/lib/agent-context-parser.ts
+++ b/apps/ui/src/lib/agent-context-parser.ts
@@ -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')) 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';
diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts
index 89aa07ba..22079822 100644
--- a/apps/ui/src/lib/electron.ts
+++ b/apps/ui/src/lib/electron.ts
@@ -1442,6 +1442,7 @@ interface SetupAPI {
verifyClaudeAuth: (authMethod?: 'cli' | 'api_key') => Promise<{
success: boolean;
authenticated: boolean;
+ authType?: 'oauth' | 'api_key' | 'cli';
error?: string;
}>;
getGhStatus?: () => Promise<{
diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts
index 1f79ff07..acd75d22 100644
--- a/apps/ui/src/lib/http-api-client.ts
+++ b/apps/ui/src/lib/http-api-client.ts
@@ -1350,6 +1350,7 @@ export class HttpApiClient implements ElectronAPI {
): Promise<{
success: boolean;
authenticated: boolean;
+ authType?: 'oauth' | 'api_key' | 'cli';
error?: string;
}> => this.post('/api/setup/verify-claude-auth', { authMethod, apiKey }),
diff --git a/apps/ui/tests/features/edit-feature.spec.ts b/apps/ui/tests/features/edit-feature.spec.ts
index 8b1c9dca..1d05c4a5 100644
--- a/apps/ui/tests/features/edit-feature.spec.ts
+++ b/apps/ui/tests/features/edit-feature.spec.ts
@@ -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
diff --git a/docs/llm-shared-packages.md b/docs/llm-shared-packages.md
index 9a81ad90..9f558c96 100644
--- a/docs/llm-shared-packages.md
+++ b/docs/llm-shared-packages.md
@@ -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
diff --git a/docs/pr/terminal-omp.png b/docs/pr/terminal-omp.png
new file mode 100644
index 00000000..11488b54
Binary files /dev/null and b/docs/pr/terminal-omp.png differ
diff --git a/docs/server/providers.md b/docs/server/providers.md
index 757ecab1..4dae626e 100644
--- a/docs/server/providers.md
+++ b/docs/server/providers.md
@@ -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
});
diff --git a/docs/server/utilities.md b/docs/server/utilities.md
index b12e60a2..91d301bb 100644
--- a/docs/server/utilities.md
+++ b/docs/server/utilities.md
@@ -213,7 +213,7 @@ Model alias mapping for Claude models.
export const CLAUDE_MODEL_MAP: Record = {
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""
```
---
diff --git a/docs/terminal-custom-configs-plan.md b/docs/terminal-custom-configs-plan.md
new file mode 100644
index 00000000..92ea53c6
--- /dev/null
+++ b/docs/terminal-custom-configs-plan.md
@@ -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;
+export async function writeRcFiles(
+ projectPath: string,
+ theme: ThemeMode,
+ config: TerminalConfig
+): Promise;
+export function getRcFilePath(projectPath: string, shell: 'bash' | 'zsh' | 'sh'): string;
+export async function checkRcFileVersion(projectPath: string): Promise;
+export async function needsRegeneration(
+ projectPath: string,
+ theme: ThemeMode,
+ config: TerminalConfig
+): Promise;
+```
+
+**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;
+
+ /** 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;
+
+ /** 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 {
+ 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 (
+
+ {/* Enable Toggle with Warning */}
+
+
+
+
Creates custom shell configs in .automaker/terminal/