diff --git a/.github/actions/setup-cmux/action.yml b/.github/actions/setup-cmux/action.yml
index 4c1fb0d65..e15f216b8 100644
--- a/.github/actions/setup-cmux/action.yml
+++ b/.github/actions/setup-cmux/action.yml
@@ -62,3 +62,14 @@ runs:
sudo apt-get install -y --no-install-recommends imagemagick
fi
convert --version | head -1
+ - name: Install ImageMagick (Windows)
+ if: inputs.install-imagemagick == 'true' && runner.os == 'Windows'
+ shell: powershell
+ run: |
+ if (Get-Command magick -ErrorAction SilentlyContinue) {
+ Write-Host "✅ ImageMagick already available"
+ } else {
+ Write-Host "📦 Installing ImageMagick..."
+ choco install -y imagemagick
+ }
+ magick --version | Select-Object -First 1
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 0d236cc80..aaff074b7 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -58,3 +58,34 @@ jobs:
run: bun x electron-builder --linux --publish always
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ build-windows:
+ name: Build and Release Windows
+ runs-on: windows-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0 # Required for git describe to find tags
+
+ - uses: ./.github/actions/setup-cmux
+ with:
+ install-imagemagick: true
+
+ - name: Install GNU Make (for build)
+ run: choco install -y make
+
+ - name: Verify tools
+ shell: bash
+ run: |
+ make --version
+ bun --version
+ magick --version | head -1
+
+ - name: Build application
+ run: bun run build
+
+ - name: Package and publish for Windows (.exe)
+ run: bun x electron-builder --win --publish always
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/terminal-bench.yml b/.github/workflows/terminal-bench.yml
index 50cb87418..d0107868a 100644
--- a/.github/workflows/terminal-bench.yml
+++ b/.github/workflows/terminal-bench.yml
@@ -4,34 +4,34 @@ on:
workflow_call:
inputs:
model_name:
- description: 'Model to use (e.g., anthropic:claude-sonnet-4-5)'
+ description: "Model to use (e.g., anthropic:claude-sonnet-4-5)"
required: false
type: string
thinking_level:
- description: 'Thinking level (off, low, medium, high)'
+ description: "Thinking level (off, low, medium, high)"
required: false
type: string
dataset:
- description: 'Terminal-Bench dataset to use'
+ description: "Terminal-Bench dataset to use"
required: false
type: string
- default: 'terminal-bench-core==0.1.1'
+ default: "terminal-bench-core==0.1.1"
concurrency:
- description: 'Number of concurrent tasks (--n-concurrent)'
+ description: "Number of concurrent tasks (--n-concurrent)"
required: false
type: string
- default: '4'
+ default: "4"
livestream:
- description: 'Enable livestream mode (verbose output to console)'
+ description: "Enable livestream mode (verbose output to console)"
required: false
type: boolean
default: false
sample_size:
- description: 'Number of random tasks to run (empty = all tasks)'
+ description: "Number of random tasks to run (empty = all tasks)"
required: false
type: string
extra_args:
- description: 'Additional arguments to pass to terminal-bench'
+ description: "Additional arguments to pass to terminal-bench"
required: false
type: string
secrets:
@@ -42,34 +42,34 @@ on:
workflow_dispatch:
inputs:
dataset:
- description: 'Terminal-Bench dataset to use'
+ description: "Terminal-Bench dataset to use"
required: false
- default: 'terminal-bench-core==0.1.1'
+ default: "terminal-bench-core==0.1.1"
type: string
concurrency:
- description: 'Number of concurrent tasks (--n-concurrent)'
+ description: "Number of concurrent tasks (--n-concurrent)"
required: false
- default: '4'
+ default: "4"
type: string
livestream:
- description: 'Enable livestream mode (verbose output to console)'
+ description: "Enable livestream mode (verbose output to console)"
required: false
default: false
type: boolean
sample_size:
- description: 'Number of random tasks to run (empty = all tasks)'
+ description: "Number of random tasks to run (empty = all tasks)"
required: false
type: string
model_name:
- description: 'Model to use (e.g., anthropic:claude-sonnet-4-5, openai:gpt-5-codex)'
+ description: "Model to use (e.g., anthropic:claude-sonnet-4-5, openai:gpt-5-codex)"
required: false
type: string
thinking_level:
- description: 'Thinking level (off, low, medium, high)'
+ description: "Thinking level (off, low, medium, high)"
required: false
type: string
extra_args:
- description: 'Additional arguments to pass to terminal-bench'
+ description: "Additional arguments to pass to terminal-bench"
required: false
type: string
@@ -147,4 +147,3 @@ jobs:
benchmark.log
if-no-files-found: warn
retention-days: 30
-
diff --git a/Makefile b/Makefile
index a27559132..2c3ff9a07 100644
--- a/Makefile
+++ b/Makefile
@@ -24,6 +24,19 @@
# Branches reduce reproducibility - builds should fail fast with clear errors
# if dependencies are missing, not silently fall back to different behavior.
+# Use PATH-resolved bash on Windows to avoid hardcoded /usr/bin/bash which doesn't
+# exist in Chocolatey's make environment or on GitHub Actions windows-latest.
+ifeq ($(OS),Windows_NT)
+SHELL := bash
+# Windows: Use npm/npx because bun x doesn't correctly pass arguments on Windows
+RUNNER := npx
+else
+SHELL := /bin/bash
+# Non-Windows: Use bun x for better performance
+RUNNER := bun x
+endif
+.SHELLFLAGS := -eu -o pipefail -c
+
# Enable parallel execution by default (only if user didn't specify -j)
ifeq (,$(filter -j%,$(MAKEFLAGS)))
MAKEFLAGS += -j
@@ -32,7 +45,7 @@ endif
# Include formatting rules
include fmt.mk
-.PHONY: all build dev start clean help
+.PHONY: all build dev start clean clean-cache help
.PHONY: build-renderer version build-icons build-static
.PHONY: lint lint-fix typecheck static-check
.PHONY: test test-unit test-integration test-watch test-coverage test-e2e
@@ -91,26 +104,33 @@ help: ## Show this help message
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}'
## Development
-dev: node_modules/.installed build-main ## Start development server (Vite + tsgo watcher for 10x faster type checking)
- @bun x concurrently -k \
- "bun x concurrently \"$(TSGO) -w -p tsconfig.main.json\" \"bun x tsc-alias -w -p tsconfig.main.json\"" \
+dev: node_modules/.installed build-main clean-cache ## Start development server (Vite + nodemon watcher for Windows compatibility)
+ @echo "Starting dev mode (2 watchers: nodemon for main process, vite for renderer)..."
+ @# On Windows, use npm run because bun x doesn't correctly pass arguments
+ @NODE_OPTIONS="--max-old-space-size=4096" $(RUNNER) concurrently -k --raw \
+ "$(RUNNER) nodemon --exec node scripts/build-main-watch.js" \
"vite"
+clean-cache: ## Clean Vite cache (helps with EMFILE errors on Windows)
+ @echo "Cleaning Vite cache..."
+ @rm -rf node_modules/.vite
+
dev-server: node_modules/.installed build-main ## Start server mode with hot reload (backend :3000 + frontend :5173). Use VITE_HOST=0.0.0.0 BACKEND_HOST=0.0.0.0 for remote access
@echo "Starting dev-server..."
@echo " Backend (IPC/WebSocket): http://$(or $(BACKEND_HOST),localhost):$(or $(BACKEND_PORT),3000)"
@echo " Frontend (with HMR): http://$(or $(VITE_HOST),localhost):$(or $(VITE_PORT),5173)"
@echo ""
@echo "For remote access: make dev-server VITE_HOST=0.0.0.0 BACKEND_HOST=0.0.0.0"
- @bun x concurrently -k \
- "bun x concurrently \"$(TSGO) -w -p tsconfig.main.json\" \"bun x tsc-alias -w -p tsconfig.main.json\"" \
- "bun x nodemon --watch dist/main.js --watch dist/main-server.js --delay 500ms --exec 'node dist/main.js server --host $(or $(BACKEND_HOST),localhost) --port $(or $(BACKEND_PORT),3000)'" \
- "CMUX_VITE_HOST=$(or $(VITE_HOST),127.0.0.1) CMUX_VITE_PORT=$(or $(VITE_PORT),5173) VITE_BACKEND_URL=http://$(or $(BACKEND_HOST),localhost):$(or $(BACKEND_PORT),3000) vite"
+ @# On Windows, use npm run because bun x doesn't correctly pass arguments
+ @$(RUNNER) concurrently -k \
+ "$(RUNNER) nodemon --exec node scripts/build-main-watch.js" \
+ "$(RUNNER) nodemon --watch dist/main.js --watch dist/main-server.js --delay 500ms --exec \"node dist/main.js server --host $(or $(BACKEND_HOST),localhost) --port $(or $(BACKEND_PORT),3000)\"" \
+ "$(SHELL) -lc \"CMUX_VITE_HOST=$(or $(VITE_HOST),127.0.0.1) CMUX_VITE_PORT=$(or $(VITE_PORT),5173) VITE_BACKEND_URL=http://$(or $(BACKEND_HOST),localhost):$(or $(BACKEND_PORT),3000) vite\""
start: node_modules/.installed build-main build-preload build-static ## Build and start Electron app
- @bun x electron --remote-debugging-port=9222 .
+ @$(RUNNER) electron --remote-debugging-port=9222 .
## Build targets (can run in parallel)
build: node_modules/.installed src/version.ts build-renderer build-main build-preload build-icons build-static ## Build all targets
@@ -120,7 +140,7 @@ build-main: node_modules/.installed dist/main.js ## Build main process
dist/main.js: src/version.ts tsconfig.main.json tsconfig.json $(TS_SOURCES)
@echo "Building main process..."
@NODE_ENV=production $(TSGO) -p tsconfig.main.json
- @NODE_ENV=production bun x tsc-alias -p tsconfig.main.json
+ @NODE_ENV=production $(RUNNER) tsc-alias -p tsconfig.main.json
build-preload: node_modules/.installed dist/preload.js ## Build preload script
@@ -135,7 +155,7 @@ dist/preload.js: src/preload.ts $(TS_SOURCES)
build-renderer: node_modules/.installed src/version.ts ## Build renderer process
@echo "Building renderer..."
- @bun x vite build
+ @$(RUNNER) vite build
build-static: ## Copy static assets to dist
@echo "Copying static assets..."
@@ -162,16 +182,16 @@ MAGICK_CMD := $(shell command -v magick 2>/dev/null || command -v convert 2>/dev
build/icon.png: docs/img/logo.webp
@echo "Generating Linux icon..."
@mkdir -p build
- @$(MAGICK_CMD) docs/img/logo.webp -resize 512x512 build/icon.png
+ @"$(MAGICK_CMD)" docs/img/logo.webp -resize 512x512 build/icon.png
build/icon.icns: docs/img/logo.webp
@echo "Generating macOS icon..."
@mkdir -p build/icon.iconset
@for size in 16 32 64 128 256 512; do \
- $(MAGICK_CMD) docs/img/logo.webp -resize $${size}x$${size} build/icon.iconset/icon_$${size}x$${size}.png; \
+ "$(MAGICK_CMD)" docs/img/logo.webp -resize $${size}x$${size} build/icon.iconset/icon_$${size}x$${size}.png; \
if [ $$size -le 256 ]; then \
double=$$((size * 2)); \
- $(MAGICK_CMD) docs/img/logo.webp -resize $${double}x$${double} build/icon.iconset/icon_$${size}x$${size}@2x.png; \
+ "$(MAGICK_CMD)" docs/img/logo.webp -resize $${double}x$${double} build/icon.iconset/icon_$${size}x$${size}@2x.png; \
fi; \
done
@iconutil -c icns build/icon.iconset -o build/icon.icns
@@ -187,7 +207,8 @@ lint-fix: node_modules/.installed ## Run linter with --fix
@./scripts/lint.sh --fix
typecheck: node_modules/.installed src/version.ts ## Run TypeScript type checking (uses tsgo for 10x speedup)
- @bun x concurrently -g \
+ @# On Windows, use npm run because bun x doesn't correctly pass arguments
+ @$(RUNNER) concurrently -g \
"$(TSGO) --noEmit" \
"$(TSGO) --noEmit -p tsconfig.main.json"
@@ -195,7 +216,7 @@ check-deadcode: node_modules/.installed ## Check for potential dead code (manual
@echo "Checking for potential dead code with ts-prune..."
@echo "(Note: Some unused exports are legitimate - types, public APIs, entry points, etc.)"
@echo ""
- @bun x ts-prune -i '(test|spec|mock|bench|debug|storybook)' \
+ @$(RUNNER) ts-prune -i '(test|spec|mock|bench|debug|storybook)' \
| grep -v "used in module" \
| grep -v "src/App.tsx.*default" \
| grep -v "src/types/" \
@@ -205,7 +226,7 @@ check-deadcode: node_modules/.installed ## Check for potential dead code (manual
## Testing
test-integration: node_modules/.installed build-main ## Run all tests (unit + integration)
@bun test src
- @TEST_INTEGRATION=1 bun x jest tests
+ @TEST_INTEGRATION=1 $(RUNNER) jest tests
test-unit: node_modules/.installed build-main ## Run unit tests
@bun test src
@@ -220,22 +241,22 @@ test-coverage: ## Run tests with coverage
test-e2e: ## Run end-to-end tests
@$(MAKE) build
- @CMUX_E2E_LOAD_DIST=1 CMUX_E2E_SKIP_BUILD=1 PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 bun x playwright test --project=electron $(PLAYWRIGHT_ARGS)
+ @CMUX_E2E_LOAD_DIST=1 CMUX_E2E_SKIP_BUILD=1 PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 $(RUNNER) playwright test --project=electron $(PLAYWRIGHT_ARGS)
## Distribution
dist: build ## Build distributable packages
- @bun x electron-builder --publish never
+ @$(RUNNER) electron-builder --publish never
# Parallel macOS builds - notarization happens concurrently
dist-mac: build ## Build macOS distributables (x64 + arm64)
@if [ -n "$$CSC_LINK" ]; then \
echo "🔐 Code signing enabled - building sequentially to avoid keychain conflicts..."; \
- bun x electron-builder --mac --x64 --publish never && \
- bun x electron-builder --mac --arm64 --publish never; \
+ $(RUNNER) electron-builder --mac --x64 --publish never && \
+ $(RUNNER) electron-builder --mac --arm64 --publish never; \
else \
echo "Building macOS architectures in parallel..."; \
- bun x electron-builder --mac --x64 --publish never & pid1=$$! ; \
- bun x electron-builder --mac --arm64 --publish never & pid2=$$! ; \
+ $(RUNNER) electron-builder --mac --x64 --publish never & pid1=$$! ; \
+ $(RUNNER) electron-builder --mac --arm64 --publish never & pid2=$$! ; \
wait $$pid1 && wait $$pid2; \
fi
@echo "✅ Both architectures built successfully"
@@ -243,29 +264,29 @@ dist-mac: build ## Build macOS distributables (x64 + arm64)
dist-mac-release: build ## Build and publish macOS distributables (x64 + arm64)
@if [ -n "$$CSC_LINK" ]; then \
echo "🔐 Code signing enabled - building sequentially to avoid keychain conflicts..."; \
- bun x electron-builder --mac --x64 --publish always && \
- bun x electron-builder --mac --arm64 --publish always; \
+ $(RUNNER) electron-builder --mac --x64 --publish always && \
+ $(RUNNER) electron-builder --mac --arm64 --publish always; \
else \
echo "Building and publishing macOS architectures in parallel..."; \
- bun x electron-builder --mac --x64 --publish always & pid1=$$! ; \
- bun x electron-builder --mac --arm64 --publish always & pid2=$$! ; \
+ $(RUNNER) electron-builder --mac --x64 --publish always & pid1=$$! ; \
+ $(RUNNER) electron-builder --mac --arm64 --publish always & pid2=$$! ; \
wait $$pid1 && wait $$pid2; \
fi
@echo "✅ Both architectures built and published successfully"
dist-mac-x64: build ## Build macOS x64 distributable only
@echo "Building macOS x64..."
- @bun x electron-builder --mac --x64 --publish never
+ @$(RUNNER) electron-builder --mac --x64 --publish never
dist-mac-arm64: build ## Build macOS arm64 distributable only
@echo "Building macOS arm64..."
- @bun x electron-builder --mac --arm64 --publish never
+ @$(RUNNER) electron-builder --mac --arm64 --publish never
dist-win: build ## Build Windows distributable
- @bun x electron-builder --win --publish never
+ @$(RUNNER) electron-builder --win --publish never
dist-linux: build ## Build Linux distributable
- @bun x electron-builder --linux --publish never
+ @$(RUNNER) electron-builder --linux --publish never
## Documentation
docs: ## Serve documentation locally
@@ -280,19 +301,19 @@ docs-watch: ## Watch and rebuild documentation
## Storybook
storybook: node_modules/.installed ## Start Storybook development server
$(check_node_version)
- @bun x storybook dev -p 6006 $(STORYBOOK_OPEN_FLAG)
+ @$(RUNNER) storybook dev -p 6006 $(STORYBOOK_OPEN_FLAG)
storybook-build: node_modules/.installed src/version.ts ## Build static Storybook
$(check_node_version)
- @bun x storybook build
+ @$(RUNNER) storybook build
test-storybook: node_modules/.installed ## Run Storybook interaction tests (requires Storybook to be running or built)
$(check_node_version)
- @bun x test-storybook
+ @$(RUNNER) test-storybook
chromatic: node_modules/.installed ## Run Chromatic for visual regression testing
$(check_node_version)
- @bun x chromatic --exit-zero-on-changes
+ @$(RUNNER) chromatic --exit-zero-on-changes
## Benchmarks
benchmark-terminal: ## Run Terminal-Bench with the cmux agent (use TB_DATASET/TB_SAMPLE_SIZE/TB_TIMEOUT/TB_ARGS to customize)
diff --git a/bun.lock b/bun.lock
index 29f3f6229..60af5ca48 100644
--- a/bun.lock
+++ b/bun.lock
@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
+ "configVersion": 0,
"workspaces": {
"": {
"name": "mux",
diff --git a/docs/theme/copy-buttons.js b/docs/theme/copy-buttons.js
index 12b5f7867..35bbc87ce 100644
--- a/docs/theme/copy-buttons.js
+++ b/docs/theme/copy-buttons.js
@@ -3,29 +3,32 @@
* Attaches click handlers to pre-rendered buttons
*/
-(function() {
- 'use strict';
+(function () {
+ "use strict";
// Initialize copy buttons after DOM loads
- if (document.readyState === 'loading') {
- document.addEventListener('DOMContentLoaded', initCopyButtons);
+ if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", initCopyButtons);
} else {
initCopyButtons();
}
function initCopyButtons() {
- document.querySelectorAll('.code-copy-button').forEach(function(button) {
- button.addEventListener('click', function() {
- var wrapper = button.closest('.code-block-wrapper');
+ document.querySelectorAll(".code-copy-button").forEach(function (button) {
+ button.addEventListener("click", function () {
+ var wrapper = button.closest(".code-block-wrapper");
var code = wrapper.dataset.code;
-
+
if (navigator.clipboard && navigator.clipboard.writeText) {
- navigator.clipboard.writeText(code).then(function() {
- showFeedback(button, true);
- }).catch(function(err) {
- console.warn('Failed to copy:', err);
- showFeedback(button, false);
- });
+ navigator.clipboard
+ .writeText(code)
+ .then(function () {
+ showFeedback(button, true);
+ })
+ .catch(function (err) {
+ console.warn("Failed to copy:", err);
+ showFeedback(button, false);
+ });
} else {
// Fallback for older browsers
fallbackCopy(code);
@@ -37,7 +40,7 @@
function showFeedback(button, success) {
var originalContent = button.innerHTML;
-
+
// Match the main app's CopyButton feedback - show "Copied!" text
if (success) {
button.innerHTML = 'Copied!';
@@ -45,21 +48,21 @@
button.innerHTML = 'Failed!';
}
button.disabled = true;
-
- setTimeout(function() {
+
+ setTimeout(function () {
button.innerHTML = originalContent;
button.disabled = false;
}, 2000);
}
function fallbackCopy(text) {
- var textarea = document.createElement('textarea');
+ var textarea = document.createElement("textarea");
textarea.value = text;
- textarea.style.position = 'fixed';
- textarea.style.opacity = '0';
+ textarea.style.position = "fixed";
+ textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
- document.execCommand('copy');
+ document.execCommand("copy");
document.body.removeChild(textarea);
}
})();
diff --git a/docs/theme/custom.css b/docs/theme/custom.css
index 8c48e22b9..dc2ef0f94 100644
--- a/docs/theme/custom.css
+++ b/docs/theme/custom.css
@@ -510,9 +510,8 @@ details[open] > summary::before {
background-repeat: no-repeat;
}
-
/* Page TOC (Table of Contents) overrides */
-@media only screen and (min-width:1440px) {
+@media only screen and (min-width: 1440px) {
.pagetoc a {
/* Reduce vertical spacing for more compact TOC */
padding-top: 2px !important;
@@ -546,10 +545,6 @@ details[open] > summary::before {
}
}
-
-
-
-
/* Code block wrapper with line numbers and copy button (from cmux app) */
.code-block-wrapper {
position: relative;
diff --git a/package.json b/package.json
index d64845575..aab160fbe 100644
--- a/package.json
+++ b/package.json
@@ -204,7 +204,9 @@
"artifactName": "${productName}-${version}-${arch}.${ext}"
},
"win": {
- "target": "nsis"
+ "target": "nsis",
+ "icon": "build/icon.png",
+ "artifactName": "${productName}-${version}-${arch}.${ext}"
}
}
}
diff --git a/public/service-worker.js b/public/service-worker.js
index 7d8eba2d1..961d9f026 100644
--- a/public/service-worker.js
+++ b/public/service-worker.js
@@ -51,7 +51,20 @@ self.addEventListener("fetch", (event) => {
})
.catch(() => {
// If network fails, try cache
- return caches.match(event.request);
+ return caches.match(event.request).then((cachedResponse) => {
+ // If cache has it, return it; otherwise return a proper error response
+ if (cachedResponse) {
+ return cachedResponse;
+ }
+ // Return a proper Response object for failed requests
+ return new Response("Network error and no cached version available", {
+ status: 503,
+ statusText: "Service Unavailable",
+ headers: new Headers({
+ "Content-Type": "text/plain",
+ }),
+ });
+ });
})
);
});
diff --git a/scripts/build-main-watch.js b/scripts/build-main-watch.js
new file mode 100644
index 000000000..6709782c5
--- /dev/null
+++ b/scripts/build-main-watch.js
@@ -0,0 +1,35 @@
+#!/usr/bin/env node
+/**
+ * Build script for main process in watch mode
+ * Used by nodemon - ignores file arguments passed by nodemon
+ */
+
+const { execSync } = require("child_process");
+const path = require("path");
+
+const rootDir = path.join(__dirname, "..");
+const tsgoPath = path.join(rootDir, "node_modules/@typescript/native-preview/bin/tsgo.js");
+const tscAliasPath = path.join(rootDir, "node_modules/tsc-alias/dist/bin/index.js");
+
+try {
+ console.log("Building main process...");
+
+ // Run tsgo
+ execSync(`node "${tsgoPath}" -p tsconfig.main.json`, {
+ cwd: rootDir,
+ stdio: "inherit",
+ env: { ...process.env, NODE_ENV: "development" },
+ });
+
+ // Run tsc-alias
+ execSync(`node "${tscAliasPath}" -p tsconfig.main.json`, {
+ cwd: rootDir,
+ stdio: "inherit",
+ env: { ...process.env, NODE_ENV: "development" },
+ });
+
+ console.log("✓ Main process build complete");
+} catch (error) {
+ console.error("Build failed:", error.message);
+ process.exit(1);
+}
diff --git a/scripts/mdbook-shiki.ts b/scripts/mdbook-shiki.ts
index f46abbe95..5be73f86e 100755
--- a/scripts/mdbook-shiki.ts
+++ b/scripts/mdbook-shiki.ts
@@ -6,7 +6,11 @@
*/
import { createHighlighter } from "shiki";
-import { SHIKI_THEME, mapToShikiLang, extractShikiLines } from "../src/utils/highlighting/shiki-shared";
+import {
+ SHIKI_THEME,
+ mapToShikiLang,
+ extractShikiLines,
+} from "../src/utils/highlighting/shiki-shared";
import { renderToStaticMarkup } from "react-dom/server";
import { CodeBlockSSR } from "../src/components/Messages/CodeBlockSSR";
@@ -44,33 +48,34 @@ type PreprocessorInput = [Context, Book];
*/
function generateGridHtml(shikiHtml: string, originalCode: string): string {
const lines = extractShikiLines(shikiHtml);
-
+
// Render the React component to static HTML
- const html = renderToStaticMarkup(
- CodeBlockSSR({ code: originalCode, highlightedLines: lines })
- );
-
+ const html = renderToStaticMarkup(CodeBlockSSR({ code: originalCode, highlightedLines: lines }));
+
return html;
}
/**
* Process markdown content to replace code blocks with highlighted HTML
*/
-async function processMarkdown(content: string, highlighter: Awaited>): Promise {
+async function processMarkdown(
+ content: string,
+ highlighter: Awaited>
+): Promise {
// Match ```lang\ncode\n``` blocks (lang is optional)
const codeBlockRegex = /```(\w*)\n([\s\S]*?)```/g;
-
+
let result = content;
const matches = Array.from(content.matchAll(codeBlockRegex));
-
+
for (const match of matches) {
const [fullMatch, lang, code] = match;
// Default to plaintext if no language specified
const shikiLang = mapToShikiLang(lang || "plaintext");
-
+
// Remove trailing newlines from code (markdown often has extra newline before closing ```)
const trimmedCode = code.replace(/\n+$/, "");
-
+
try {
// Load language if needed
const loadedLangs = highlighter.getLoadedLanguages();
@@ -84,36 +89,39 @@ async function processMarkdown(content: string, highlighter: Awaited>): Promise {
+async function processChapter(
+ chapter: Chapter,
+ highlighter: Awaited>
+): Promise {
if (chapter.content) {
chapter.content = await processMarkdown(chapter.content, highlighter);
}
-
+
if (chapter.sub_items) {
for (const subItem of chapter.sub_items) {
if (subItem.Chapter) {
@@ -129,7 +137,7 @@ async function processChapter(chapter: Chapter, highlighter: Awaited((resolve) => {
- const proc = spawn("bash", ["-c", `"${hookPath}"`], {
+ const bashPath = getBashPath();
+ const proc = spawn(bashPath, ["-c", `"${hookPath}"`], {
cwd: workspacePath,
stdio: ["ignore", "pipe", "pipe"],
});
diff --git a/src/runtime/SSHRuntime.ts b/src/runtime/SSHRuntime.ts
index 138a064cb..4343d206d 100644
--- a/src/runtime/SSHRuntime.ts
+++ b/src/runtime/SSHRuntime.ts
@@ -1,7 +1,6 @@
import { spawn } from "child_process";
import { Readable, Writable } from "stream";
import * as path from "path";
-import { Shescape } from "shescape";
import type {
Runtime,
ExecOptions,
@@ -25,12 +24,20 @@ import { getProjectName } from "../utils/runtime/helpers";
import { getErrorMessage } from "../utils/errors";
import { execAsync, DisposableProcess } from "../utils/disposableExec";
import { getControlPath } from "./sshConnectionPool";
+import { getBashPath } from "../utils/bashPath";
/**
- * Shescape instance for bash shell escaping.
+ * Shell-escape helper for remote bash.
* Reused across all SSH runtime operations for performance.
*/
-const shescape = new Shescape({ shell: "bash" });
+const shescape = {
+ quote(value: unknown): string {
+ const s = String(value);
+ if (s.length === 0) return "''";
+ // Use POSIX-safe pattern to embed single quotes within single-quoted strings
+ return "'" + s.replace(/'/g, "'\"'\"'") + "'";
+ },
+};
/**
* SSH Runtime Configuration
@@ -574,7 +581,8 @@ export class SSHRuntime implements Runtime {
const command = `cd ${shescape.quote(projectPath)} && git bundle create - --all | ssh ${sshArgs.join(" ")} "cat > ${bundleTempPath}"`;
log.debug(`Creating bundle: ${command}`);
- const proc = spawn("bash", ["-c", command]);
+ const bashPath = getBashPath();
+ const proc = spawn(bashPath, ["-c", command]);
const cleanup = streamProcessToLogger(proc, initLogger, {
logStdout: false,
diff --git a/src/services/bashExecutionService.ts b/src/services/bashExecutionService.ts
index 623165f03..175376b43 100644
--- a/src/services/bashExecutionService.ts
+++ b/src/services/bashExecutionService.ts
@@ -1,6 +1,7 @@
import { spawn } from "child_process";
import type { ChildProcess } from "child_process";
import { log } from "./log";
+import { getBashPath } from "../utils/bashPath";
/**
* Configuration for bash execution
@@ -120,10 +121,13 @@ export class BashExecutionService {
`BashExecutionService: Script: ${script.substring(0, 100)}${script.length > 100 ? "..." : ""}`
);
- const spawnCommand = config.niceness !== undefined ? "nice" : "bash";
+ // Windows doesn't have nice command, so just spawn bash directly
+ const isWindows = process.platform === "win32";
+ const bashPath = getBashPath();
+ const spawnCommand = config.niceness !== undefined && !isWindows ? "nice" : bashPath;
const spawnArgs =
- config.niceness !== undefined
- ? ["-n", config.niceness.toString(), "bash", "-c", script]
+ config.niceness !== undefined && !isWindows
+ ? ["-n", config.niceness.toString(), bashPath, "-c", script]
: ["-c", script];
const child = spawn(spawnCommand, spawnArgs, {
diff --git a/src/utils/bashPath.ts b/src/utils/bashPath.ts
new file mode 100644
index 000000000..e0f44e7f5
--- /dev/null
+++ b/src/utils/bashPath.ts
@@ -0,0 +1,116 @@
+/**
+ * Platform-specific bash path resolution
+ *
+ * On Unix/Linux/macOS, bash is in PATH by default.
+ * On Windows, bash comes from Git Bash and needs to be located.
+ */
+
+import { execSync } from "child_process";
+import { existsSync } from "fs";
+import path from "path";
+
+let cachedBashPath: string | null = null;
+
+/**
+ * Find bash executable path on Windows
+ * Checks common Git Bash installation locations
+ */
+function findWindowsBash(): string | null {
+ // Common Git Bash installation paths
+ const commonPaths = [
+ // Git for Windows default paths
+ "C:\\Program Files\\Git\\bin\\bash.exe",
+ "C:\\Program Files (x86)\\Git\\bin\\bash.exe",
+ // User-local Git installation
+ path.join(process.env.LOCALAPPDATA || "", "Programs", "Git", "bin", "bash.exe"),
+ // Portable Git
+ path.join(process.env.USERPROFILE || "", "scoop", "apps", "git", "current", "bin", "bash.exe"),
+ // Chocolatey installation
+ "C:\\tools\\git\\bin\\bash.exe",
+ ];
+
+ // Check if bash is in PATH first
+ try {
+ const result = execSync("where bash", { encoding: "utf8", stdio: ["pipe", "pipe", "ignore"] });
+ const firstPath = result.split("\n")[0].trim();
+ if (firstPath && existsSync(firstPath)) {
+ return firstPath;
+ }
+ } catch {
+ // Not in PATH, continue to check common locations
+ }
+
+ // Check common installation paths
+ for (const bashPath of commonPaths) {
+ if (existsSync(bashPath)) {
+ return bashPath;
+ }
+ }
+
+ // Also check if Git is in PATH and derive bash path from it
+ try {
+ const gitPath = execSync("where git", { encoding: "utf8", stdio: ["pipe", "pipe", "ignore"] });
+ const firstGitPath = gitPath.split("\n")[0].trim();
+ if (firstGitPath) {
+ // Git is usually in Git/cmd/git.exe, bash is in Git/bin/bash.exe
+ const gitDir = path.dirname(path.dirname(firstGitPath));
+ const bashPath = path.join(gitDir, "bin", "bash.exe");
+ if (existsSync(bashPath)) {
+ return bashPath;
+ }
+ // Also try usr/bin/bash.exe (newer Git for Windows structure)
+ const usrBashPath = path.join(gitDir, "usr", "bin", "bash.exe");
+ if (existsSync(usrBashPath)) {
+ return usrBashPath;
+ }
+ }
+ } catch {
+ // Git not in PATH
+ }
+
+ return null;
+}
+
+/**
+ * Get the bash executable path for the current platform
+ *
+ * @returns Path to bash executable. On Unix/macOS returns "bash",
+ * on Windows returns full path to bash.exe if found.
+ * @throws Error if bash cannot be found on Windows
+ */
+export function getBashPath(): string {
+ // On Unix/Linux/macOS, bash is in PATH
+ if (process.platform !== "win32") {
+ return "bash";
+ }
+
+ // Use cached path if available
+ if (cachedBashPath !== null) {
+ return cachedBashPath;
+ }
+
+ // Find bash on Windows
+ const bashPath = findWindowsBash();
+ if (!bashPath) {
+ throw new Error(
+ "Git Bash not found. Please install Git for Windows from https://git-scm.com/download/win"
+ );
+ }
+
+ cachedBashPath = bashPath;
+ return bashPath;
+}
+
+/**
+ * Check if bash is available on the system
+ *
+ * @returns true if bash is available, false otherwise
+ */
+export function isBashAvailable(): boolean {
+ try {
+ getBashPath();
+ return true;
+ } catch {
+ return false;
+ }
+}
diff --git a/tsconfig.json b/tsconfig.json
index 33d44c08e..a567f709c 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -18,6 +18,10 @@
"@/*": ["./src/*"]
}
},
+ "watchOptions": {
+ "excludeDirectories": ["**/node_modules", "**/dist", "**/build", "**/.git"],
+ "excludeFiles": ["**/*.d.ts.map"]
+ },
"include": [
"src/**/*.tsx",
"src/**/*.ts",
diff --git a/tsconfig.main.json b/tsconfig.main.json
index b63625bb8..489e5f627 100644
--- a/tsconfig.main.json
+++ b/tsconfig.main.json
@@ -6,6 +6,10 @@
"noEmit": false,
"sourceMap": true
},
+ "watchOptions": {
+ "excludeDirectories": ["**/node_modules", "**/dist", "**/build", "**/.git"],
+ "excludeFiles": ["**/*.d.ts.map"]
+ },
"include": [
"src/main.ts",
"src/main-server.ts",
diff --git a/vite.config.ts b/vite.config.ts
index 0816c1d2b..4609528d0 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -87,6 +87,33 @@ export default defineConfig(({ mode }) => ({
strictPort: true,
allowedHosts: devServerHost === "0.0.0.0" ? undefined : ["localhost", "127.0.0.1"],
sourcemapIgnoreList: () => false, // Show all sources in DevTools
+
+ watch: {
+ // Ignore node_modules to drastically reduce file handle usage
+ ignored: ["**/node_modules/**", "**/dist/**", "**/.git/**"],
+
+ // Use polling on Windows to avoid file handle exhaustion
+ // This is slightly less efficient but much more stable
+ usePolling: process.platform === "win32",
+
+ // If using polling, set a reasonable interval (in milliseconds)
+ interval: 1000,
+
+ // Limit the depth of directory traversal
+ depth: 3,
+
+ // Additional options for Windows specifically
+ ...(process.platform === "win32" && {
+ // Increase the binary interval for better Windows performance
+ binaryInterval: 1000,
+ // Use a more conservative approach to watching
+ awaitWriteFinish: {
+ stabilityThreshold: 500,
+ pollInterval: 100,
+ },
+ }),
+ },
+
hmr: {
// Configure HMR to use the correct host for remote access
host: devServerHost,
@@ -104,5 +131,24 @@ export default defineConfig(({ mode }) => ({
esbuildOptions: {
target: "esnext",
},
+
+ // Force include CommonJS packages that need pre-bundling
+ include: ["fast-deep-equal"],
+
+ // Exclude problematic large packages from optimization
+ // These packages have many files and can cause EMFILE errors on Windows
+ exclude: [
+ "langium",
+ "vscode-languageserver",
+ "vscode-languageserver-protocol",
+ "vscode-languageserver-types",
+ "vscode-languageclient",
+ ],
+
+ // Include only what's actually imported to reduce scanning
+ entries: ["src/**/*.{ts,tsx}"],
+
+ // Force re-optimize dependencies
+ force: false,
},
}));