Compare commits

..

No commits in common. "df06be7bc7c9da6d717e1f0c5cf4f9d0ff450c94" and "d6f853a49725c4eaa25103832adefd13680fc5f8" have entirely different histories.

20 changed files with 135 additions and 3824 deletions

View file

@ -9,50 +9,7 @@ permissions:
contents: write
jobs:
create-release:
runs-on: ubuntu-latest
outputs:
upload_url: ${{ steps.create_release.outputs.upload_url }}
steps:
- name: Check out Git repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Generate release notes
id: release_notes
run: |
# Get the previous tag
PREVIOUS_TAG=$(git tag --sort=-version:refname | grep -v "$(git describe --tags --abbrev=0)" | head -n 1)
# Generate changelog
if [ -z "$PREVIOUS_TAG" ]; then
CHANGELOG=$(git log --pretty=format:"- %s (%h)" ${{ github.ref_name }})
else
CHANGELOG=$(git log --pretty=format:"- %s (%h)" ${PREVIOUS_TAG}..${{ github.ref_name }})
fi
# Save to output
echo "CHANGELOG<<EOF" >> $GITHUB_OUTPUT
echo "$CHANGELOG" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Create Release
id: create_release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref_name }}
release_name: Release ${{ github.ref_name }}
body: |
## Changes
${{ steps.release_notes.outputs.CHANGELOG }}
draft: false
prerelease: ${{ contains(github.ref_name, 'beta') || contains(github.ref_name, 'alpha') }}
build:
needs: create-release
release:
runs-on: ${{ matrix.os }}
strategy:

View file

@ -1,43 +0,0 @@
#!/usr/bin/env bash
# Script to patch Electron for NixOS
set -e
if [ ! -d "node_modules/electron/dist" ]; then
echo "Electron not installed yet. Run 'npm install' first."
exit 1
fi
echo "Patching Electron binaries for NixOS..."
cd node_modules/electron/dist
# Patch main electron binary
if [ -f electron ]; then
patchelf --set-interpreter "$(cat $NIX_CC/nix-support/dynamic-linker)" electron
patchelf --set-rpath "${LD_LIBRARY_PATH}" electron
echo "✓ Patched electron"
fi
# Patch chrome-sandbox
if [ -f chrome-sandbox ]; then
chmod u+w chrome-sandbox
patchelf --set-interpreter "$(cat $NIX_CC/nix-support/dynamic-linker)" chrome-sandbox || true
patchelf --set-rpath "${LD_LIBRARY_PATH}" chrome-sandbox || true
echo "✓ Patched chrome-sandbox"
fi
# Copy libffmpeg from the system
if [ ! -f libffmpeg.so ]; then
# Try to find libffmpeg in the system
for ffmpeg_lib in /nix/store/*-electron-*/lib/libffmpeg.so; do
if [ -f "$ffmpeg_lib" ]; then
cp "$ffmpeg_lib" .
chmod +w libffmpeg.so
echo "✓ Copied libffmpeg.so"
break
fi
done
fi
echo "Electron patching complete!"

View file

@ -1,172 +0,0 @@
# Amp Integration in FleetCode
Amp has been added as a coding agent option in FleetCode, alongside Claude and Codex.
## What Changed
### Files Modified
1. **[index.html](file:///home/anish/usr/FleetCode/index.html)** - Added Amp to the coding agent dropdown
2. **[server.ts](file:///home/anish/usr/FleetCode/server.ts)** - Added Amp command execution
3. **[main.ts](file:///home/anish/usr/FleetCode/main.ts)** - Added Amp support for Electron mode
4. **[renderer.ts](file:///home/anish/usr/FleetCode/renderer.ts)** - Updated UI logic for Amp
5. **[README.md](file:///home/anish/usr/FleetCode/README.md)** - Updated documentation
## How It Works
When you select "Amp" as your coding agent and create a session:
1. FleetCode creates a new git worktree for the session
2. Spawns a terminal (PTY) in that worktree
3. Runs any setup commands you configured
4. Executes `amp` command in the terminal
## Using Amp
### Prerequisites
Amp is run via `npx` and doesn't need to be installed system-wide:
```bash
# Amp is run using:
nix shell nixpkgs#nodejs -c npx -y @sourcegraph/amp
# This automatically:
# - Provides Node.js via Nix
# - Downloads and runs the latest @sourcegraph/amp package
```
### Creating an Amp Session
1. Click "New Session" in FleetCode
2. Select your project directory (git repository)
3. Choose the parent branch
4. **Select "Amp" from the Coding Agent dropdown**
5. Optionally add setup commands
6. Click "Create Session"
FleetCode will:
- Create a git worktree
- Start a terminal session
- Launch `amp` in the worktree
## Features
### What Works with Amp
✅ Git worktree isolation
✅ Persistent sessions
✅ Terminal theming
✅ Setup commands (environment variables, etc.)
✅ Session management (rename, close, delete)
### Differences from Claude
Unlike Claude, Amp doesn't require:
- `--session-id` or `--resume` flags
- `--dangerously-skip-permissions` flag
- MCP server configuration
Amp sessions are simpler - just run `amp` and it starts.
## Code Implementation
### Terminal Command
When you select Amp, FleetCode runs:
```bash
nix shell nixpkgs#nodejs -c npx -y @sourcegraph/amp
```
This uses Nix to provide Node.js and npx to run the latest Amp package.
Claude in comparison requires session management:
```bash
claude --session-id <uuid>
```
### UI Behavior
- **Skip Permissions checkbox**: Hidden for Amp (only needed for Claude)
- **Session persistence**: Works the same as other agents
- **MCP servers**: Not shown for Amp sessions
## Customization
If you need to pass additional flags to Amp, you can:
1. Add them in the **Setup Commands** field:
```bash
export AMP_CONFIG=/path/to/config
```
2. Or modify the code in `server.ts` and `main.ts`:
```typescript
} else if (config.codingAgent === "amp") {
ptyProcess.write("nix shell nixpkgs#nodejs -c npx -y @sourcegraph/amp --your-flags-here\r");
}
```
## Testing
To test Amp integration:
```bash
# Start the web server
npm run server
# Or for Electron
npm run dev
```
Then:
1. Create a new session
2. Select "Amp" as the coding agent
3. Choose a project
4. Verify that `amp` launches in the terminal
## Troubleshooting
### "nix: command not found"
Nix is not installed. Install it from https://nixos.org/download.html
### "npx: command not found"
The nix shell should provide Node.js and npx. If this fails, check your Nix installation:
```bash
# Test Nix shell
nix shell nixpkgs#nodejs -c node --version
```
### Amp doesn't start
Check the terminal output in the FleetCode session - you'll see any errors from the `amp` command.
### Want to pass custom flags?
Edit the setup commands or modify `server.ts`/`main.ts` as shown above.
## Future Enhancements
Possible improvements for Amp integration:
- [ ] Amp-specific settings panel
- [ ] Custom Amp flags input field
- [ ] Amp session persistence (if Amp supports session IDs)
- [ ] Amp-specific MCP server integration
- [ ] Amp status indicators
## Summary
Amp is now fully integrated into FleetCode! You can:
1. ✅ Select Amp from the coding agent dropdown
2. ✅ Create Amp sessions in isolated git worktrees
3. ✅ Run multiple Amp sessions in parallel
4. ✅ Use all FleetCode features (themes, persistence, etc.)
Enjoy using Amp in FleetCode! 🚀

View file

@ -1,117 +0,0 @@
# NixOS Development Setup
This project includes a `flake.nix` for reproducible development on NixOS and other Nix-based systems.
## Quick Start
1. **Enter the development shell:**
```bash
nix develop
```
2. **First-time setup:**
```bash
npm install
npm run rebuild # Rebuild native modules for Electron
```
3. **Build and run:**
```bash
npm run dev
```
## What's Included
The Nix development environment provides:
- **Node.js 20** - JavaScript runtime
- **Electron 38** - Desktop application framework (from nixpkgs, not npm)
- **Build tools**: git, python3 (with setuptools), pkg-config, gcc, make
- **Electron dependencies**: All required system libraries (X11, GTK3, Mesa, etc.)
- **Proper library paths**: Automatically configured `LD_LIBRARY_PATH`
## Why Use Nix?
- **Reproducible**: Same versions of tools across all machines
- **Isolated**: No conflicts with system packages
- **Complete**: All dependencies included, nothing to install manually
- **NixOS-friendly**: Electron binaries work out of the box
## Important Notes
### System Electron vs npm Electron
This setup uses the Electron binary from nixpkgs instead of the npm package. This is necessary on NixOS because:
1. npm Electron binaries are dynamically linked for generic Linux
2. NixOS has a unique library structure that requires patching
3. nixpkgs provides pre-patched, working Electron binaries
The environment variable `ELECTRON_OVERRIDE_DIST_PATH` tells npm scripts to use the system Electron.
### Native Modules
After `npm install`, you **must** run `npm run rebuild` to rebuild native modules (like `node-pty`) for the Electron version:
```bash
npm run rebuild # or: npx electron-rebuild
```
This ensures native modules are compiled against Electron's Node.js version, not your system Node.js.
## Available Commands
```bash
# Setup
npm install # Install npm dependencies
npm run rebuild # Rebuild native modules for Electron
# Development
npm run build # Build TypeScript and Tailwind CSS
npm run dev # Build and run Electron app
electron . # Run the already-built app
# Production
npm run pack # Package for distribution (directory)
npm run dist # Build distributable packages
```
## Troubleshooting
### "Cannot find module" errors for native modules
Run `npm run rebuild` to recompile native modules for Electron.
### "Missing X server or $DISPLAY" error
This is normal if you're running from SSH or a headless environment. The app needs a display server (X11 or Wayland). If you see this briefly when running locally with a display, it's just the app starting up.
### Library path issues
The flake automatically sets `LD_LIBRARY_PATH` for all required libraries. If you encounter missing library errors, check that you're inside the `nix develop` shell.
## Directory Layout
```
FleetCode/
├── flake.nix # Nix flake configuration
├── flake.lock # Locked dependency versions
├── package.json # npm dependencies and scripts
├── main.ts # Electron main process
├── renderer.ts # Electron renderer process
├── dist/ # Compiled output (generated)
└── node_modules/ # npm dependencies (generated)
```
## Updating Dependencies
### Nix dependencies
```bash
nix flake update
```
### npm dependencies
```bash
npm update
npm run rebuild # Don't forget to rebuild!
```

View file

@ -6,7 +6,7 @@ A desktop terminal application for running multiple CLI coding agents simultaneo
## Features
- **Multiple Sessions**: Run multiple coding agent sessions (Claude, Codex, Amp) in parallel
- **Multiple Sessions**: Run multiple coding agent sessions (Claude, Codex) in parallel
- **Git Worktree Isolation**: Each session runs in its own git worktree, keeping work isolated
- **Persistent Sessions**: Sessions persist across app restarts with automatic resumption
- **Terminal Theming**: Choose from preset themes (macOS Light/Dark, Solarized Dark, Dracula, One Dark, GitHub Dark)
@ -18,7 +18,7 @@ A desktop terminal application for running multiple CLI coding agents simultaneo
- Node.js 16+
- Git
- Claude CLI (`npm install -g @anthropic-ai/claude-cli`), Codex, or Amp
- Claude CLI (`npm install -g @anthropic-ai/claude-cli`) or Codex
## Installation
@ -47,7 +47,7 @@ npm start
1. Select a project directory (must be a git repository)
2. Choose a parent branch for the worktree
3. Select your coding agent (Claude, Codex, or Amp)
3. Select your coding agent (Claude or Codex)
4. Optionally add setup commands (e.g., environment variables, source files)
5. FleetCode creates a new git worktree and spawns a terminal session

View file

@ -1,111 +0,0 @@
# 🚀 Start FleetCode Web Server
## Quick Start
```bash
# 1. Enter Nix development environment
nix develop
# 2. Install dependencies (first time only)
npm install
# 3. Start the web server
npm run server
```
## Access the Application
### From This Machine
```
http://localhost:3000
```
### From Network (LAN)
Your server will be accessible at:
```
http://192.168.1.240:3000
```
### From Internet
See [WEB_SERVER_SETUP.md](WEB_SERVER_SETUP.md) for remote access options.
## Server Commands
```bash
# Default port (3000)
npm run server
# Custom port
PORT=8080 npm run server
# Build only (without starting)
npm run build
```
## What's Running?
- **HTTP Server**: Express on port 3000 (or custom PORT)
- **WebSocket**: Socket.IO for real-time terminal communication
- **Static Files**: Serves index.html and compiled assets
- **Terminal Backend**: node-pty for terminal session management
## Stopping the Server
Press `Ctrl+C` in the terminal where the server is running.
## Troubleshooting
### "Port already in use"
```bash
# Use different port
PORT=8080 npm run server
# Or find and kill process using port 3000
lsof -ti:3000 | xargs kill -9
```
### "Cannot find module" errors
```bash
# Reinstall dependencies
npm install
# Rebuild native modules for Node.js
npm rebuild node-pty --update-binary
```
### Can't access from network
```bash
# Check firewall (NixOS)
sudo iptables -L | grep 3000
# Temporarily open port
sudo iptables -A INPUT -p tcp --dport 3000 -j ACCEPT
```
## Next Steps
1. ✅ Server is running locally
2. 📱 Access from your phone/tablet on same network: `http://192.168.1.240:3000`
3. 🌍 Set up remote access (see WEB_SERVER_SETUP.md)
4. 🔐 Add authentication before exposing to internet
5. 🔒 Configure HTTPS with reverse proxy
## Files Created
- `server.ts` - Web server implementation
- `WEB_SERVER_SETUP.md` - Detailed setup guide
- `flake.nix` - Updated with web server commands
## Architecture
```
Browser (Any Device)
↓ HTTP/WebSocket
Express Server (port 3000)
↓ Socket.IO
Terminal Sessions (node-pty)
Git Worktrees + Claude/Codex
```
Enjoy FleetCode! 🎉

View file

@ -1,181 +0,0 @@
# FleetCode Web Server Setup
FleetCode can run as a web server, making it accessible from any device on your network or over the internet.
## Quick Start
```bash
# Enter Nix development shell
nix develop
# Install dependencies (first time only)
npm install
# Start the web server
npm run server
```
The server will start on `http://0.0.0.0:3000` by default.
## Accessing the Server
### Local Access
Open your browser and navigate to:
```
http://localhost:3000
```
### Network Access
From other devices on your network:
```
http://<your-machine-ip>:3000
```
To find your IP address:
```bash
# On Linux
ip addr show | grep "inet "
# Or
hostname -I
```
### Remote Access (Internet)
To access FleetCode over the internet, you have several options:
#### Option 1: Port Forwarding (if you control your router)
1. Forward port 3000 on your router to your machine
2. Access via your public IP: `http://<your-public-ip>:3000`
3. **Important**: Add authentication/security before exposing to internet
#### Option 2: Tailscale/ZeroTier (recommended for security)
1. Install Tailscale or ZeroTier on your machine
2. Access via your Tailscale/ZeroTier IP from any device on your network
#### Option 3: Reverse Proxy (nginx/caddy + Let's Encrypt)
Set up a reverse proxy with HTTPS for secure access
## Custom Port
To use a different port:
```bash
PORT=8080 npm run server
```
## Architecture
The web server version uses:
- **Express** - HTTP server
- **Socket.IO** - Real-time bidirectional communication
- **node-pty** - Terminal emulation
- **xterm.js** - Terminal UI in the browser
The same `index.html` and terminal logic works for both:
- **Web mode**: Uses Socket.IO instead of Electron IPC
- **Desktop mode**: Uses Electron IPC for communication
## Development
### Running the Server
```bash
npm run server # Build and start
npm run dev:server # Same as above
```
### Building Only
```bash
npm run build # Build TypeScript and CSS
```
### File Structure
```
FleetCode/
├── server.ts # Web server (Express + Socket.IO)
├── main.ts # Electron main process
├── renderer.ts # Browser/renderer code
├── index.html # UI (works for both modes)
├── dist/
│ ├── server.js # Compiled web server
│ ├── main.js # Compiled Electron main
│ └── styles.css # Compiled Tailwind CSS
```
## Security Considerations
⚠️ **Important**: The default setup has no authentication!
Before exposing to the internet:
1. **Add Authentication**
- Implement user login
- Use session tokens
- Consider OAuth/OIDC
2. **Use HTTPS**
- Set up reverse proxy (nginx/caddy)
- Get SSL certificate (Let's Encrypt)
3. **Firewall Rules**
- Only allow specific IPs if possible
- Use VPN for remote access
4. **Environment Variables**
- Don't commit secrets
- Use `.env` files (not tracked in git)
## Troubleshooting
### Port Already in Use
```bash
# Find what's using the port
sudo lsof -i :3000
# Use a different port
PORT=8080 npm run server
```
### Cannot Connect from Network
1. Check firewall:
```bash
# On NixOS, check firewall status
sudo iptables -L
# Allow port temporarily
sudo iptables -A INPUT -p tcp --dport 3000 -j ACCEPT
```
2. Verify server is listening on 0.0.0.0 (not 127.0.0.1)
### Native Module Errors
If you see errors about `node-pty`:
```bash
# Rebuild for Node.js (not Electron)
npm rebuild node-pty --update-binary
```
## Comparison: Web Server vs Desktop App
| Feature | Web Server | Desktop App |
|---------|------------|-------------|
| Remote Access | ✅ Yes | ❌ No |
| Installation | Browser only | Electron required |
| Performance | Network dependent | Native speed |
| Multi-user | Possible (with auth) | Single user |
| Setup | Simple | More complex |
## Next Steps
1. **Add Authentication**: Implement user login system
2. **HTTPS Setup**: Configure reverse proxy with SSL
3. **Multi-user Support**: Session isolation per user
4. **Monitoring**: Add logging and metrics
5. **Docker**: Containerize for easy deployment

36
dev.sh
View file

@ -1,36 +0,0 @@
#!/usr/bin/env bash
# Development helper script for FleetCode on NixOS
set -e
echo "🚀 FleetCode Development Script"
echo ""
# Check if we're in a nix shell
if [ -z "$ELECTRON_OVERRIDE_DIST_PATH" ]; then
echo "📦 Entering Nix development shell..."
exec nix develop --command bash "$0" "$@"
fi
# Check if node_modules exists
if [ ! -d "node_modules" ]; then
echo "📦 Installing dependencies..."
npm install
echo ""
fi
# Check if native modules are built
if [ ! -f "node_modules/node-pty/build/Release/pty.node" ]; then
echo "🔧 Rebuilding native modules for Electron..."
npm run rebuild
echo ""
fi
# Build and run
echo "🏗️ Building application..."
npm run build
echo ""
echo "🚀 Starting FleetCode..."
echo ""
electron .

View file

@ -1,61 +0,0 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1760038930,
"narHash": "sha256-Oncbh0UmHjSlxO7ErQDM3KM0A5/Znfofj2BSzlHLeVw=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "0b4defa2584313f3b781240b29d61f6f9f7e0df3",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

130
flake.nix
View file

@ -1,130 +0,0 @@
{
description = "FleetCode - Multiple CLI coding agents development environment";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = nixpkgs.legacyPackages.${system};
in
{
devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [
# Node.js and package managers
nodejs_20
# Electron (use nixpkgs version)
electron_38
# Build tools
git
(python3.withPackages (ps: with ps; [ setuptools ]))
pkg-config
# Native build dependencies for node-pty and Electron
gcc
gnumake
# Electron runtime dependencies
xorg.libX11
xorg.libxcb
xorg.libXcomposite
xorg.libXcursor
xorg.libXdamage
xorg.libXext
xorg.libXfixes
xorg.libXi
xorg.libXrandr
xorg.libXrender
xorg.libXtst
xorg.libXScrnSaver
libdrm
mesa
gtk3
pango
cairo
gdk-pixbuf
atk
glib
nspr
nss
cups
dbus
expat
at-spi2-atk
at-spi2-core
libxkbcommon
alsa-lib
# Optional: Claude CLI if available
# (claude-cli)
];
shellHook = ''
echo "🚀 FleetCode development environment loaded"
echo ""
echo "📦 Setup (first time):"
echo " npm install - Install dependencies"
echo ""
echo "🌐 Web Server (recommended for remote access):"
echo " npm run server - Start web server on port 3000"
echo " PORT=8080 npm run server - Start on custom port"
echo ""
echo "🖥 Desktop App (Electron):"
echo " npx electron-rebuild - Rebuild for Electron"
echo " npm run dev - Run Electron app"
echo ""
echo "Note: Web server uses Node.js, desktop app uses Electron"
echo ""
# Set up Electron to find system libraries
export LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath (with pkgs; [
xorg.libX11
xorg.libxcb
xorg.libXcomposite
xorg.libXcursor
xorg.libXdamage
xorg.libXext
xorg.libXfixes
xorg.libXi
xorg.libXrandr
xorg.libXrender
xorg.libXtst
xorg.libXScrnSaver
libdrm
mesa
gtk3
pango
cairo
gdk-pixbuf
atk
glib
nspr
nss
cups
dbus
expat
at-spi2-atk
at-spi2-core
libxkbcommon
alsa-lib
stdenv.cc.cc.lib
])}:$LD_LIBRARY_PATH"
# Disable Electron's sandbox for development
export ELECTRON_DISABLE_SANDBOX=1
# Set npm config for native builds
export npm_config_build_from_source=true
# Use system electron instead of npm package
export ELECTRON_OVERRIDE_DIST_PATH="${pkgs.electron_38}/bin/"
'';
};
}
);
}

View file

@ -2,12 +2,9 @@
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>FleetCode Terminal</title>
<link rel="stylesheet" href="node_modules/xterm/css/xterm.css" />
<link rel="stylesheet" href="dist/styles.css" />
<!-- Socket.IO client for web mode -->
<script src="/socket.io/socket.io.js"></script>
<style>
body {
margin: 0;
@ -17,19 +14,9 @@
</style>
</head>
<body class="h-screen overflow-hidden bg-gray-900">
<!-- Mobile hamburger menu -->
<button id="mobile-menu-btn" class="mobile-menu-btn" title="Toggle Menu">
<span class="hamburger-line"></span>
<span class="hamburger-line"></span>
<span class="hamburger-line"></span>
</button>
<!-- Mobile overlay (when sidebar is open) -->
<div id="mobile-overlay" class="mobile-overlay"></div>
<div class="flex h-full">
<!-- Sidebar -->
<div class="sidebar" id="sidebar">
<div class="sidebar">
<div class="sidebar-header">
<h1 class="sidebar-title">FleetCode</h1>
<button id="open-settings" class="settings-btn" title="Settings">⚙️</button>
@ -95,7 +82,6 @@
<select id="coding-agent" class="form-select">
<option value="claude">Claude</option>
<option value="codex">Codex</option>
<option value="amp">Amp</option>
</select>
</div>
@ -257,21 +243,7 @@
</div>
<script>
// Detect if running in Electron or Web browser
const isElectron = typeof window !== "undefined" &&
typeof window.process !== "undefined" &&
window.process.type === "renderer";
if (isElectron) {
// Electron: use require (CommonJS from tsc)
require('./dist/renderer.js');
} else {
// Web browser: use bundled version (IIFE from esbuild)
const script = document.createElement('script');
script.src = './dist/renderer.web.js';
script.type = 'text/javascript';
document.body.appendChild(script);
}
require('./dist/renderer.js');
</script>
</body>
</html>

View file

@ -1,98 +0,0 @@
// Communication adapter that works in both Electron and Web modes
import { io, Socket } from "socket.io-client";
type EventCallback = (event: any, ...args: any[]) => void;
export interface IPCAdapter {
send(channel: string, ...args: any[]): void;
invoke(channel: string, ...args: any[]): Promise<any>;
on(channel: string, callback: EventCallback): void;
}
class ElectronAdapter implements IPCAdapter {
private ipcRenderer: any;
constructor() {
this.ipcRenderer = require("electron").ipcRenderer;
}
send(channel: string, ...args: any[]): void {
this.ipcRenderer.send(channel, ...args);
}
invoke(channel: string, ...args: any[]): Promise<any> {
return this.ipcRenderer.invoke(channel, ...args);
}
on(channel: string, callback: EventCallback): void {
this.ipcRenderer.on(channel, callback);
}
}
class WebAdapter implements IPCAdapter {
private socket: Socket;
private responseHandlers = new Map<number, { resolve: (value: any) => void; reject: (reason?: any) => void }>();
private requestCounter = 0;
constructor() {
this.socket = io();
// Handle invoke responses from server
this.socket.on("invoke-response", (requestId: number, error: any, result: any) => {
const handler = this.responseHandlers.get(requestId);
if (handler) {
this.responseHandlers.delete(requestId);
if (error) {
handler.reject(error);
} else {
handler.resolve(result);
}
}
});
}
send(channel: string, ...args: any[]): void {
this.socket.emit(channel, ...args);
}
invoke(channel: string, ...args: any[]): Promise<any> {
return new Promise((resolve, reject) => {
const requestId = this.requestCounter++;
this.responseHandlers.set(requestId, { resolve, reject });
// Set timeout for invoke
setTimeout(() => {
if (this.responseHandlers.has(requestId)) {
this.responseHandlers.delete(requestId);
reject(new Error(`Invoke timeout: ${channel}`));
}
}, 10000);
this.socket.emit("invoke", requestId, channel, ...args);
});
}
on(channel: string, callback: EventCallback): void {
this.socket.on(channel, (...args: any[]) => {
// Mimic Electron's event object structure
const event = {};
callback(event, ...args);
});
}
}
// Detect environment and create appropriate adapter
function createAdapter(): IPCAdapter {
// Check if running in Electron
const isElectron = typeof window !== "undefined" &&
typeof (window as any).process !== "undefined" &&
(window as any).process.type === "renderer";
if (isElectron) {
return new ElectronAdapter();
} else {
return new WebAdapter();
}
}
export const ipcAdapter = createAdapter();

18
main.ts
View file

@ -9,7 +9,6 @@ import {simpleGit} from "simple-git";
import {promisify} from "util";
import {v4 as uuidv4} from "uuid";
import {PersistedSession, SessionConfig} from "./types";
import {isTerminalReady} from "./terminal-utils";
const execAsync = promisify(exec);
@ -32,6 +31,12 @@ function getWorktreeBaseDir(): string {
return path.join(os.homedir(), "worktrees");
}
function isTerminalReady(buffer: string, startPos: number = 0): boolean {
const searchBuffer = buffer.slice(startPos);
return searchBuffer.includes("\x1b[?2004h", startPos);
}
function savePersistedSessions(sessions: PersistedSession[]) {
(store as any).set("sessions", sessions);
}
@ -134,14 +139,13 @@ function spawnMcpPoller(sessionId: string, projectDir: string) {
const serverMap = new Map<string, any>();
ptyProcess.onData((data) => {
// Accumulate output without displaying it
outputBuffer += data;
// Parse output whenever we have MCP server entries
// Match lines like: "servername: url (type) - ✓ Connected" or "servername: command (stdio) - ✓ Connected"
// Pattern handles SSE, stdio, and HTTP types (case-insensitive) with success (✓), warning (⚠), or failure (✗) status
const mcpServerLineRegex = /^[\w-]+:.+\((?:SSE|sse|stdio|HTTP)\)\s+-\s+[✓⚠]/mi;
// Pattern handles SSE, stdio, and HTTP types
const mcpServerLineRegex = /^[\w-]+:.+\((?:SSE|stdio|HTTP)\)\s+-\s+[✓⚠]/m;
if (mcpServerLineRegex.test(data) || data.includes("No MCP servers configured")) {
try {
@ -213,7 +217,7 @@ function parseMcpOutput(output: string): any[] {
// Only parse lines that match the MCP server format
// Must have: "name: something (SSE|stdio|HTTP) - status"
const serverMatch = line.match(/^([\w-]+):.+\((?:SSE|sse|stdio|HTTP)\)\s+-\s+[✓⚠]/i);
const serverMatch = line.match(/^([\w-]+):.+\((?:SSE|stdio|HTTP)\)\s+-\s+[✓⚠]/);
if (serverMatch) {
const serverName = serverMatch[1];
const isConnected = line.includes("✓") || line.includes("Connected");
@ -293,8 +297,6 @@ function spawnSessionPty(
}
} else if (config.codingAgent === "codex") {
ptyProcess.write("codex\r");
} else if (config.codingAgent === "amp") {
ptyProcess.write("nix shell nixpkgs#nodejs -c npx -y @sourcegraph/amp\r");
}
}
}
@ -467,7 +469,6 @@ ipcMain.on("create-session", async (event, config: SessionConfig) => {
// Handle session input
ipcMain.on("session-input", (_event, sessionId: string, data: string) => {
const ptyProcess = activePtyProcesses.get(sessionId);
if (ptyProcess) {
ptyProcess.write(data);
}
@ -507,7 +508,6 @@ ipcMain.on("reopen-session", (event, sessionId: string) => {
// Close session (kill PTY but keep session)
ipcMain.on("close-session", (_event, sessionId: string) => {
const ptyProcess = activePtyProcesses.get(sessionId);
if (ptyProcess) {
ptyProcess.kill();
activePtyProcesses.delete(sessionId);

View file

@ -1,10 +0,0 @@
{
"watch": ["dist"],
"ext": "js",
"delay": "500",
"verbose": false,
"ignore": ["dist/styles.css"],
"execMap": {
"js": "node"
}
}

1762
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,23 +1,14 @@
{
"name": "fleetcode",
"version": "1.0.1-beta.7",
"version": "1.0.1-beta.6",
"description": "Run multiple CLI coding agents at once",
"main": "dist/main.js",
"scripts": {
"build:css": "npx tailwindcss -i styles.css -o dist/styles.css",
"build:ts": "tsc",
"build:web": "esbuild renderer.ts --bundle --outfile=dist/renderer.web.js --format=iife --platform=browser --external:electron",
"build": "npm run build:css && npm run build:ts",
"build:all": "npm run build:css && npm run build:ts && npm run build:web",
"watch:css": "npx tailwindcss -i styles.css -o dist/styles.css --watch",
"watch:ts": "tsc --watch",
"watch:web": "esbuild renderer.ts --bundle --outfile=dist/renderer.web.js --format=iife --platform=browser --external:electron --watch",
"watch:server": "nodemon --watch dist --exec node dist/server.js",
"rebuild": "npx electron-rebuild",
"start": "npm run build && electron .",
"dev": "npm run build && electron .",
"server": "npm run build:all && node dist/server.js",
"dev:server": "npm run build:all && concurrently --names \"CSS,WEB,TS,SRV\" -c \"cyan,magenta,yellow,green\" \"npm:watch:css\" \"npm:watch:web\" \"tsc --watch\" \"npm:watch:server\"",
"pack": "npm run build && electron-builder --dir",
"dist": "npm run build && electron-builder",
"test": "echo \"Error: no test specified\" && exit 1"
@ -32,28 +23,19 @@
"@types/node": "^24.6.2",
"@types/uuid": "^10.0.0",
"autoprefixer": "^10.4.21",
"concurrently": "^9.2.1",
"electron": "^38.2.0",
"electron-builder": "^26.0.12",
"electron-rebuild": "^3.2.9",
"esbuild": "^0.25.10",
"nodemon": "^3.1.10",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.18",
"ts-node": "^10.9.2",
"typescript": "^5.9.3"
},
"dependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.3",
"@xterm/addon-fit": "^0.10.0",
"cors": "^2.8.5",
"electron-store": "^11.0.0",
"express": "^5.1.0",
"node-pty": "^1.0.0",
"simple-git": "^3.28.0",
"socket.io": "^4.8.1",
"socket.io-client": "^4.8.1",
"uuid": "^13.0.0",
"xterm": "^5.3.0"
},

View file

@ -1,8 +1,7 @@
import {FitAddon} from "@xterm/addon-fit";
import {ipcRenderer} from "electron";
import {Terminal} from "xterm";
import {PersistedSession, SessionConfig} from "./types";
import {isClaudeSessionReady} from "./terminal-utils";
import {ipcAdapter} from "./ipc-adapter";
interface Session {
id: string;
@ -13,6 +12,7 @@ interface Session {
config: SessionConfig;
worktreePath: string;
hasActivePty: boolean;
hasUnreadActivity: boolean;
}
interface McpServer {
@ -198,28 +198,13 @@ function getSystemTheme(): "light" | "dark" {
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
// Get default worktree directory (different for Electron vs Browser)
function getDefaultWorktreeDir(): string {
try {
// Try to use Node.js modules (Electron)
if (typeof require !== 'undefined') {
const path = require("path");
const os = require("os");
return path.join(os.homedir(), "worktrees");
}
} catch (e) {
// Fallback for browser
}
return "~/worktrees";
}
// Default settings - macOS Terminal matching system theme
const DEFAULT_SETTINGS: TerminalSettings = {
fontFamily: "Menlo, Monaco, 'Courier New', monospace",
fontSize: 11,
theme: getSystemTheme() === "dark" ? "macos-dark" : "macos-light",
cursorBlink: false,
worktreeDir: getDefaultWorktreeDir(),
worktreeDir: require("path").join(require("os").homedir(), "worktrees"),
};
const sessions = new Map<string, Session>();
@ -228,9 +213,6 @@ let mcpServers: McpServer[] = [];
let mcpPollerActive = false;
let terminalSettings: TerminalSettings = { ...DEFAULT_SETTINGS };
// Track activity timers for each session
const activityTimers = new Map<string, NodeJS.Timeout>();
function createTerminalUI(sessionId: string) {
const themeColors = THEME_PRESETS[terminalSettings.theme] || THEME_PRESETS["macos-dark"];
@ -256,7 +238,7 @@ function createTerminalUI(sessionId: string) {
term.open(sessionElement);
term.onData((data) => {
ipcAdapter.send("session-input", sessionId, data);
ipcRenderer.send("session-input", sessionId, data);
});
// Listen for bell character to mark unread activity
@ -271,7 +253,7 @@ function createTerminalUI(sessionId: string) {
const proposedDimensions = fitAddon.proposeDimensions();
if (proposedDimensions) {
fitAddon.fit();
ipcAdapter.send("session-resize", sessionId, proposedDimensions.cols, proposedDimensions.rows);
ipcRenderer.send("session-resize", sessionId, proposedDimensions.cols, proposedDimensions.rows);
}
}
};
@ -290,6 +272,7 @@ function addSession(persistedSession: PersistedSession, hasActivePty: boolean) {
config: persistedSession.config,
worktreePath: persistedSession.worktreePath,
hasActivePty,
hasUnreadActivity: false,
};
sessions.set(persistedSession.id, session);
@ -307,9 +290,7 @@ function addSession(persistedSession: PersistedSession, hasActivePty: boolean) {
function activateSession(sessionId: string) {
const session = sessions.get(sessionId);
if (!session) {
return;
}
if (!session) return;
// If terminal UI doesn't exist yet, create it
if (!session.terminal) {
@ -317,12 +298,6 @@ function activateSession(sessionId: string) {
session.terminal = ui.terminal;
session.fitAddon = ui.fitAddon;
session.element = ui.element;
// Force initial fit
setTimeout(() => {
if (session.fitAddon) {
session.fitAddon.fit();
}
}, 100);
}
session.hasActivePty = true;
@ -440,7 +415,7 @@ function finishEditingSessionName(sessionId: string) {
}
// Save to backend
ipcAdapter.send("rename-session", sessionId, newName);
ipcRenderer.send("rename-session", sessionId, newName);
}
nameInput.classList.add("hidden");
@ -464,16 +439,14 @@ function cancelEditingSessionName(sessionId: string) {
function handleSessionClick(sessionId: string) {
const session = sessions.get(sessionId);
if (!session) {
return;
}
if (!session) return;
if (session.hasActivePty) {
// Just switch to it
switchToSession(sessionId);
} else {
// Reopen the session
ipcAdapter.send("reopen-session", sessionId);
ipcRenderer.send("reopen-session", sessionId);
}
}
@ -485,7 +458,6 @@ function addTab(sessionId: string, name: string) {
tab.id = `tab-${sessionId}`;
tab.className = "tab";
tab.innerHTML = `
<span class="unread-indicator"></span>
<span class="tab-name">${name}</span>
<button class="tab-close-btn" data-id="${sessionId}">×</button>
`;
@ -509,6 +481,8 @@ function markSessionAsUnread(sessionId: string) {
const session = sessions.get(sessionId);
if (!session) return;
session.hasUnreadActivity = true;
// Add unread indicator to tab
const tab = document.getElementById(`tab-${sessionId}`);
if (tab) {
@ -520,6 +494,8 @@ function clearUnreadStatus(sessionId: string) {
const session = sessions.get(sessionId);
if (!session) return;
session.hasUnreadActivity = false;
// Remove unread indicator from tab
const tab = document.getElementById(`tab-${sessionId}`);
if (tab) {
@ -527,49 +503,6 @@ function clearUnreadStatus(sessionId: string) {
}
}
function markSessionActivity(sessionId: string) {
const session = sessions.get(sessionId);
if (!session) return;
// Add activity indicator to tab
const tab = document.getElementById(`tab-${sessionId}`);
if (tab) {
tab.classList.add("activity");
tab.classList.remove("unread");
}
// Clear any existing timer
const existingTimer = activityTimers.get(sessionId);
if (existingTimer) {
clearTimeout(existingTimer);
}
// Set a new timer to remove activity after 1 second of no output
const timer = setTimeout(() => {
clearActivityStatus(sessionId);
}, 1000);
activityTimers.set(sessionId, timer);
}
function clearActivityStatus(sessionId: string) {
const session = sessions.get(sessionId);
if (!session) return;
// Remove activity indicator from tab, but keep unread if it's set
const tab = document.getElementById(`tab-${sessionId}`);
if (tab) {
tab.classList.remove("activity");
// If there's no unread status, transition to unread after activity ends
if (!tab.classList.contains("unread") && activeSessionId !== sessionId) {
tab.classList.add("unread");
}
}
// Clear the timer
activityTimers.delete(sessionId);
}
function switchToSession(sessionId: string) {
// Hide all sessions
sessions.forEach((session, id) => {
@ -582,7 +515,6 @@ function switchToSession(sessionId: string) {
// Show active session
const session = sessions.get(sessionId);
if (session && session.element && session.terminal && session.fitAddon) {
session.element.classList.add("active");
document.getElementById(`tab-${sessionId}`)?.classList.add("active");
@ -602,9 +534,15 @@ function switchToSession(sessionId: string) {
// Load MCP servers for this session
loadMcpServers();
// Clear unread and activity status when switching to this session
// Clear unread status when switching to this session
clearUnreadStatus(sessionId);
clearActivityStatus(sessionId);
// Clear any pending idle timer for this session (Bug 1 fix)
const existingTimer = sessionIdleTimers.get(sessionId);
if (existingTimer) {
clearTimeout(existingTimer);
sessionIdleTimers.delete(sessionId);
}
// Focus and resize
session.terminal.focus();
@ -637,8 +575,15 @@ function closeSession(sessionId: string) {
// Update UI indicator
updateSessionState(sessionId, false);
// Clean up idle timer (Bug 2 fix)
const existingTimer = sessionIdleTimers.get(sessionId);
if (existingTimer) {
clearTimeout(existingTimer);
sessionIdleTimers.delete(sessionId);
}
// Close PTY in main process
ipcAdapter.send("close-session", sessionId);
ipcRenderer.send("close-session", sessionId);
// Switch to another active session
if (activeSessionId === sessionId) {
@ -678,8 +623,15 @@ function deleteSession(sessionId: string) {
// Remove from sessions map
sessions.delete(sessionId);
// Clean up idle timer (Bug 2 fix)
const existingTimer = sessionIdleTimers.get(sessionId);
if (existingTimer) {
clearTimeout(existingTimer);
sessionIdleTimers.delete(sessionId);
}
// Delete in main process (handles worktree removal)
ipcAdapter.send("delete-session", sessionId);
ipcRenderer.send("delete-session", sessionId);
// Switch to another session
if (activeSessionId === sessionId) {
@ -697,8 +649,12 @@ function deleteSession(sessionId: string) {
}
}
// Track idle timers per session to detect when output stops (Claude is done)
const sessionIdleTimers = new Map<string, NodeJS.Timeout>();
const IDLE_DELAY_MS = 500; // 0.5 seconds of no output = Claude is done
// Handle session output
ipcAdapter.on("session-output", (_event, sessionId: string, data: string) => {
ipcRenderer.on("session-output", (_event, sessionId: string, data: string) => {
const session = sessions.get(sessionId);
if (session && session.terminal) {
// Filter out [3J (clear scrollback) to prevent viewport resets during interactive menus
@ -707,32 +663,35 @@ ipcAdapter.on("session-output", (_event, sessionId: string, data: string) => {
session.terminal.write(filteredData);
// Only mark as unread/activity if this is not the active session
if (activeSessionId !== sessionId && session.hasActivePty) {
// Show activity spinner while output is coming in
markSessionActivity(sessionId);
// Only mark as unread if this is not the active session
if (activeSessionId !== sessionId && session.hasActivePty && !session.hasUnreadActivity) {
// Only track substantive output (ignore cursor movements, keepalives, etc)
// Look for actual text content or common escape sequences that indicate real output
const hasSubstantiveOutput = /[a-zA-Z0-9]/.test(filteredData) ||
filteredData.includes('\n') ||
filteredData.includes('\r');
// Check if Claude session is ready for input
if (isClaudeSessionReady(filteredData)) {
// Clear activity timer and set unread
const existingTimer = activityTimers.get(sessionId);
if (hasSubstantiveOutput) {
// Clear any existing idle timer
const existingTimer = sessionIdleTimers.get(sessionId);
if (existingTimer) {
clearTimeout(existingTimer);
activityTimers.delete(sessionId);
}
const tab = document.getElementById(`tab-${sessionId}`);
if (tab) {
tab.classList.remove("activity");
tab.classList.add("unread");
}
// Set a new timer - if no output for IDLE_DELAY_MS, mark as unread
const timer = setTimeout(() => {
markSessionAsUnread(sessionId);
sessionIdleTimers.delete(sessionId);
}, IDLE_DELAY_MS);
sessionIdleTimers.set(sessionId, timer);
}
}
}
});
// Handle session created
ipcAdapter.on("session-created", (_event, sessionId: string, persistedSession: any) => {
ipcRenderer.on("session-created", (_event, sessionId: string, persistedSession: any) => {
const session = addSession(persistedSession, true);
activateSession(sessionId);
@ -765,12 +724,12 @@ ipcAdapter.on("session-created", (_event, sessionId: string, persistedSession: a
});
// Handle session reopened
ipcAdapter.on("session-reopened", (_event, sessionId: string) => {
ipcRenderer.on("session-reopened", (_event, sessionId: string) => {
activateSession(sessionId);
});
// Handle session deleted
ipcAdapter.on("session-deleted", (_event, sessionId: string) => {
ipcRenderer.on("session-deleted", (_event, sessionId: string) => {
const session = sessions.get(sessionId);
if (session) {
if (session.element) session.element.remove();
@ -791,7 +750,7 @@ ipcAdapter.on("session-deleted", (_event, sessionId: string) => {
});
// Load persisted sessions on startup
ipcAdapter.on("load-persisted-sessions", (_event, persistedSessions: PersistedSession[]) => {
ipcRenderer.on("load-persisted-sessions", (_event, persistedSessions: PersistedSession[]) => {
persistedSessions.forEach(ps => {
addSession(ps, false);
});
@ -824,14 +783,14 @@ document.getElementById("new-session")?.addEventListener("click", async () => {
modal?.classList.remove("hidden");
// Load last used settings
const lastSettings = await ipcAdapter.invoke("get-last-settings");
const lastSettings = await ipcRenderer.invoke("get-last-settings");
if (lastSettings.projectDir) {
selectedDirectory = lastSettings.projectDir;
projectDirInput.value = lastSettings.projectDir;
// Load git branches for the last directory
const branches = await ipcAdapter.invoke("get-branches", lastSettings.projectDir);
const branches = await ipcRenderer.invoke("get-branches", lastSettings.projectDir);
parentBranchSelect.innerHTML = "";
if (branches.length === 0) {
@ -866,63 +825,22 @@ document.getElementById("new-session")?.addEventListener("click", async () => {
}
// Show/hide skip permissions based on coding agent
if (lastSettings.codingAgent === "codex" || lastSettings.codingAgent === "amp") {
if (lastSettings.codingAgent === "codex") {
skipPermissionsGroup?.classList.add("hidden");
} else {
skipPermissionsGroup?.classList.remove("hidden");
}
});
// Detect if we're in web mode
const isWebMode = typeof window !== "undefined" &&
(typeof (window as any).process === "undefined" ||
(window as any).process.type !== "renderer");
// In web mode, allow manual text input for project directory
if (isWebMode && projectDirInput) {
projectDirInput.removeAttribute("readonly");
projectDirInput.placeholder = "Enter absolute path to project (e.g., /home/user/project)";
// Load branches when user types a path
projectDirInput.addEventListener("blur", async () => {
const dir = projectDirInput.value.trim();
if (dir) {
selectedDirectory = dir;
// Try to load git branches
const branches = await ipcAdapter.invoke("get-branches", dir);
parentBranchSelect.innerHTML = "";
if (branches.length === 0) {
parentBranchSelect.innerHTML = '<option value="">No git repository found</option>';
} else {
branches.forEach((branch: string) => {
const option = document.createElement("option");
option.value = branch;
option.textContent = branch;
parentBranchSelect.appendChild(option);
});
}
}
});
}
// Browse directory
browseDirBtn?.addEventListener("click", async () => {
// In web mode, show alert that manual input is needed
if (isWebMode) {
alert("Directory picker is not available in web mode. Please type the absolute path to your project directory.");
projectDirInput?.focus();
return;
}
const dir = await ipcAdapter.invoke("select-directory");
const dir = await ipcRenderer.invoke("select-directory");
if (dir) {
selectedDirectory = dir;
projectDirInput.value = dir;
// Load git branches
const branches = await ipcAdapter.invoke("get-branches", dir);
const branches = await ipcRenderer.invoke("get-branches", dir);
parentBranchSelect.innerHTML = "";
if (branches.length === 0) {
@ -948,16 +866,8 @@ cancelBtn?.addEventListener("click", () => {
// Create session button
createBtn?.addEventListener("click", () => {
// In web mode, use the input value directly if selectedDirectory isn't set
const projectDir = selectedDirectory || projectDirInput.value.trim();
if (!projectDir) {
alert("Please enter a project directory");
return;
}
if (!parentBranchSelect.value) {
alert("Please select a parent branch");
if (!selectedDirectory) {
alert("Please select a project directory");
return;
}
@ -971,7 +881,7 @@ createBtn?.addEventListener("click", () => {
const branchName = branchNameInput?.value.trim() || undefined;
const config: SessionConfig = {
projectDir: projectDir,
projectDir: selectedDirectory,
parentBranch: parentBranchSelect.value,
branchName,
codingAgent: codingAgentSelect.value,
@ -987,10 +897,10 @@ createBtn?.addEventListener("click", () => {
}
// Save settings for next time
ipcAdapter.send("save-settings", config);
ipcRenderer.send("save-settings", config);
// Create the session
ipcAdapter.send("create-session", config);
ipcRenderer.send("create-session", config);
});
// MCP Server management functions
@ -1001,7 +911,7 @@ async function loadMcpServers() {
}
try {
await ipcAdapter.invoke("list-mcp-servers", activeSessionId);
await ipcRenderer.invoke("list-mcp-servers", activeSessionId);
// Results will come via mcp-servers-updated event
} catch (error) {
console.error("Failed to load MCP servers:", error);
@ -1023,7 +933,7 @@ async function refreshMcpServers() {
try {
// Trigger MCP list command
await ipcAdapter.invoke("list-mcp-servers", activeSessionId);
await ipcRenderer.invoke("list-mcp-servers", activeSessionId);
// Wait a bit for the poller to process and send results
await new Promise(resolve => setTimeout(resolve, 500));
} catch (error) {
@ -1068,7 +978,7 @@ function renderMcpServers() {
renderMcpServers();
try {
await ipcAdapter.invoke("remove-mcp-server", server.name);
await ipcRenderer.invoke("remove-mcp-server", server.name);
} catch (error) {
alert(`Failed to remove server: ${error}`);
// Refresh to restore correct state on error
@ -1098,7 +1008,7 @@ async function showMcpServerDetails(name: string) {
detailsModal?.classList.remove("hidden");
try {
const details = await ipcAdapter.invoke("get-mcp-server-details", name);
const details = await ipcRenderer.invoke("get-mcp-server-details", name);
if (detailsContent) {
let html = "";
@ -1259,7 +1169,7 @@ addMcpBtn?.addEventListener("click", async () => {
addMcpBtn.classList.add("opacity-50", "cursor-not-allowed");
try {
await ipcAdapter.invoke("add-mcp-server", name, config);
await ipcRenderer.invoke("add-mcp-server", name, config);
mcpModal?.classList.add("hidden");
// Force immediate refresh of MCP servers
await refreshMcpServers();
@ -1296,7 +1206,7 @@ removeMcpDetailsBtn?.addEventListener("click", async () => {
renderMcpServers();
try {
await ipcAdapter.invoke("remove-mcp-server", serverName);
await ipcRenderer.invoke("remove-mcp-server", serverName);
} catch (error) {
alert(`Failed to remove server: ${error}`);
// Refresh to restore correct state on error
@ -1306,7 +1216,7 @@ removeMcpDetailsBtn?.addEventListener("click", async () => {
});
// Listen for MCP polling started event
ipcAdapter.on("mcp-polling-started", (_event, sessionId: string) => {
ipcRenderer.on("mcp-polling-started", (_event, sessionId: string) => {
if (sessionId === activeSessionId) {
const addMcpServerBtn = document.getElementById("add-mcp-server");
if (addMcpServerBtn) {
@ -1317,7 +1227,7 @@ ipcAdapter.on("mcp-polling-started", (_event, sessionId: string) => {
});
// Listen for MCP server updates from main process
ipcAdapter.on("mcp-servers-updated", (_event, sessionId: string, servers: McpServer[]) => {
ipcRenderer.on("mcp-servers-updated", (_event, sessionId: string, servers: McpServer[]) => {
// Only update if this is for the active session
if (sessionId === activeSessionId) {
mcpServers = servers;
@ -1348,7 +1258,7 @@ const browseWorktreeDirBtn = document.getElementById("browse-worktree-dir");
// Load saved settings on startup
async function loadSettings() {
const savedSettings = await ipcAdapter.invoke("get-terminal-settings");
const savedSettings = await ipcRenderer.invoke("get-terminal-settings");
if (savedSettings) {
terminalSettings = { ...DEFAULT_SETTINGS, ...savedSettings };
}
@ -1410,21 +1320,9 @@ resetSettingsBtn?.addEventListener("click", () => {
populateSettingsForm();
});
// In web mode, allow manual text input for worktree directory
if (isWebMode && settingsWorktreeDir) {
settingsWorktreeDir.removeAttribute("readonly");
}
// Browse worktree directory
browseWorktreeDirBtn?.addEventListener("click", async () => {
// In web mode, show alert that manual input is needed
if (isWebMode) {
alert("Directory picker is not available in web mode. Please type the absolute path to your worktrees directory.");
settingsWorktreeDir?.focus();
return;
}
const dir = await ipcAdapter.invoke("select-directory");
const dir = await ipcRenderer.invoke("select-directory");
if (dir) {
settingsWorktreeDir.value = dir;
}
@ -1440,7 +1338,7 @@ saveSettingsBtn?.addEventListener("click", async () => {
terminalSettings.worktreeDir = settingsWorktreeDir.value || DEFAULT_SETTINGS.worktreeDir;
// Save to electron-store
await ipcAdapter.invoke("save-terminal-settings", terminalSettings);
await ipcRenderer.invoke("save-terminal-settings", terminalSettings);
// Apply to all existing terminals
applySettingsToAllTerminals();
@ -1451,39 +1349,3 @@ saveSettingsBtn?.addEventListener("click", async () => {
// Load settings on startup
loadSettings();
// Mobile menu handling
const mobileMenuBtn = document.getElementById("mobile-menu-btn");
const mobileOverlay = document.getElementById("mobile-overlay");
const sidebar = document.getElementById("sidebar");
function toggleMobileMenu() {
const isOpen = sidebar?.classList.contains("mobile-open");
if (isOpen) {
sidebar?.classList.remove("mobile-open");
mobileOverlay?.classList.remove("active");
mobileMenuBtn?.classList.remove("active");
} else {
sidebar?.classList.add("mobile-open");
mobileOverlay?.classList.add("active");
mobileMenuBtn?.classList.add("active");
}
}
function closeMobileMenu() {
sidebar?.classList.remove("mobile-open");
mobileOverlay?.classList.remove("active");
mobileMenuBtn?.classList.remove("active");
}
mobileMenuBtn?.addEventListener("click", toggleMobileMenu);
mobileOverlay?.addEventListener("click", closeMobileMenu);
// Close mobile menu when a session is selected
document.getElementById("session-list")?.addEventListener("click", (e) => {
const target = e.target as HTMLElement;
if (target.closest(".session-list-item")) {
closeMobileMenu();
}
});

655
server.ts
View file

@ -1,655 +0,0 @@
import express from "express";
import { createServer } from "http";
import { Server as SocketIOServer } from "socket.io";
import * as fs from "fs";
import * as pty from "node-pty";
import * as os from "os";
import * as path from "path";
import { simpleGit } from "simple-git";
import { promisify } from "util";
import { v4 as uuidv4 } from "uuid";
import { PersistedSession, SessionConfig } from "./types";
import { isTerminalReady } from "./terminal-utils";
import cors from "cors";
const execAsync = promisify(require("child_process").exec);
// Helper function to expand ~ in paths
function expandTilde(filepath: string): string {
if (filepath.startsWith('~/') || filepath === '~') {
return path.join(os.homedir(), filepath.slice(1));
}
return filepath;
}
const app = express();
const httpServer = createServer(app);
const io = new SocketIOServer(httpServer, {
cors: {
origin: "*",
methods: ["GET", "POST"]
}
});
const PORT = process.env.PORT || 3000;
// Middleware
app.use(cors());
app.use(express.json());
app.use(express.static(".")); // Serve static files from current directory
// Store for sessions (simple JSON file store)
const activePtyProcesses = new Map<string, pty.IPty>();
const mcpPollerPtyProcesses = new Map<string, pty.IPty>();
// Track which sockets are connected to each session (multiple clients can connect)
const sessionSockets = new Map<string, Set<any>>();
const STORE_PATH = path.join(os.homedir(), ".fleetcode-store.json");
class SimpleStore {
private data: any = {};
constructor() {
this.load();
}
private load() {
try {
if (fs.existsSync(STORE_PATH)) {
this.data = JSON.parse(fs.readFileSync(STORE_PATH, "utf8"));
}
} catch (error) {
console.error("Error loading store:", error);
this.data = {};
}
}
private save() {
try {
fs.writeFileSync(STORE_PATH, JSON.stringify(this.data, null, 2), "utf8");
} catch (error) {
console.error("Error saving store:", error);
}
}
get(key: string, defaultValue?: any): any {
return this.data[key] !== undefined ? this.data[key] : defaultValue;
}
set(key: string, value: any) {
this.data[key] = value;
this.save();
}
}
const store = new SimpleStore();
// Helper functions (same as main.ts)
function getPersistedSessions(): PersistedSession[] {
return (store as any).get("sessions", []);
}
function getWorktreeBaseDir(): string {
const settings = (store as any).get("terminalSettings");
if (settings && settings.worktreeDir) {
return expandTilde(settings.worktreeDir);
}
return path.join(os.homedir(), "worktrees");
}
function savePersistedSessions(sessions: PersistedSession[]) {
(store as any).set("sessions", sessions);
}
function getNextSessionNumber(): number {
const sessions = getPersistedSessions();
if (sessions.length === 0) return 1;
return Math.max(...sessions.map(s => s.number)) + 1;
}
function extractProjectMcpConfig(projectDir: string): any {
try {
const claudeConfigPath = path.join(os.homedir(), ".claude.json");
if (!fs.existsSync(claudeConfigPath)) {
return {};
}
const claudeConfig = JSON.parse(fs.readFileSync(claudeConfigPath, "utf8"));
if (!claudeConfig.projects || !claudeConfig.projects[projectDir]) {
return {};
}
return claudeConfig.projects[projectDir].mcpServers || {};
} catch (error) {
console.error("Error extracting MCP config:", error);
return {};
}
}
function getProjectWorktreeDirName(projectDir: string): string {
const baseName = path.basename(projectDir);
const worktreesBaseDir = getWorktreeBaseDir();
const candidatePath = path.join(worktreesBaseDir, baseName);
if (!fs.existsSync(candidatePath)) {
return baseName;
}
const markerFile = path.join(candidatePath, ".fleetcode-project");
if (fs.existsSync(markerFile)) {
const existingProjectPath = fs.readFileSync(markerFile, "utf-8").trim();
if (existingProjectPath === projectDir) {
return baseName;
}
}
const crypto = require("crypto");
const hash = crypto.createHash("md5").update(projectDir).digest("hex").substring(0, 6);
return `${baseName}-${hash}`;
}
function writeMcpConfigFile(projectDir: string, mcpServers: any): string | null {
try {
const projectDirName = getProjectWorktreeDirName(projectDir);
const worktreesDir = getWorktreeBaseDir();
if (!fs.existsSync(worktreesDir)) {
fs.mkdirSync(worktreesDir, { recursive: true });
}
const configFilePath = path.join(worktreesDir, projectDirName, "mcp-config.json");
const configContent = JSON.stringify({ mcpServers }, null, 2);
const projectWorktreeDir = path.join(worktreesDir, projectDirName);
if (!fs.existsSync(projectWorktreeDir)) {
fs.mkdirSync(projectWorktreeDir, { recursive: true });
}
fs.writeFileSync(configFilePath, configContent, "utf8");
return configFilePath;
} catch (error) {
console.error("Error writing MCP config file:", error);
return null;
}
}
async function createWorktree(
projectDir: string,
parentBranch: string,
sessionNumber: number,
sessionUuid: string,
customBranchName?: string
): Promise<{ worktreePath: string; branchName: string }> {
const git = simpleGit(projectDir);
const projectDirName = getProjectWorktreeDirName(projectDir);
const worktreesBaseDir = getWorktreeBaseDir();
const projectWorktreeDir = path.join(worktreesBaseDir, projectDirName);
const worktreeName = customBranchName || `session${sessionNumber}`;
const worktreePath = path.join(projectWorktreeDir, worktreeName);
let branchName: string;
if (customBranchName) {
branchName = customBranchName;
} else {
const shortUuid = sessionUuid.split('-')[0];
branchName = `fleetcode/${worktreeName}-${shortUuid}`;
}
if (!fs.existsSync(projectWorktreeDir)) {
fs.mkdirSync(projectWorktreeDir, { recursive: true });
const markerFile = path.join(projectWorktreeDir, ".fleetcode-project");
fs.writeFileSync(markerFile, projectDir, "utf-8");
}
if (fs.existsSync(worktreePath)) {
try {
await git.raw(["worktree", "remove", worktreePath, "--force"]);
} catch (error) {
console.error("Error removing existing worktree:", error);
}
}
try {
await git.raw(["branch", "-D", branchName]);
} catch (error) {
// Branch doesn't exist
}
await git.raw(["worktree", "add", "-b", branchName, worktreePath, parentBranch]);
return { worktreePath, branchName };
}
async function removeWorktree(projectDir: string, worktreePath: string) {
const git = simpleGit(projectDir);
try {
await git.raw(["worktree", "remove", worktreePath, "--force"]);
} catch (error) {
console.error("Error removing worktree:", error);
}
}
async function removeGitBranch(projectDir: string, branchName: string) {
const git = simpleGit(projectDir);
try {
await git.raw(["branch", "-D", branchName]);
} catch (error) {
console.error("Error removing git branch:", error);
}
}
// Socket.IO handlers
io.on("connection", (socket) => {
console.log("Client connected:", socket.id);
// Send persisted sessions on connection
socket.emit("load-persisted-sessions", getPersistedSessions());
socket.on("create-session", async (config: SessionConfig) => {
try {
// Validate required fields
if (!config.projectDir || !config.parentBranch) {
const errorMsg = `Project directory and parent branch are required. Got: projectDir="${config.projectDir}", parentBranch="${config.parentBranch}"`;
console.error(errorMsg);
socket.emit("session-error", errorMsg);
return;
}
const sessionNumber = getNextSessionNumber();
const sessionId = `session-${Date.now()}`;
const sessionName = config.branchName || `Session ${sessionNumber}`;
const sessionUuid = uuidv4();
// Expand ~ in project directory path
const expandedProjectDir = expandTilde(config.projectDir);
const { worktreePath, branchName } = await createWorktree(
expandedProjectDir,
config.parentBranch,
sessionNumber,
sessionUuid,
config.branchName
);
// Update config with expanded path
const expandedConfig = { ...config, projectDir: expandedProjectDir };
const mcpServers = extractProjectMcpConfig(expandedProjectDir);
const mcpConfigPath = writeMcpConfigFile(expandedProjectDir, mcpServers);
const persistedSession: PersistedSession = {
id: sessionId,
number: sessionNumber,
name: sessionName,
config: expandedConfig,
worktreePath,
createdAt: Date.now(),
sessionUuid,
mcpConfigPath: mcpConfigPath || undefined,
gitBranch: branchName,
};
// Only persist and spawn PTY after worktree creation succeeds
const sessions = getPersistedSessions();
sessions.push(persistedSession);
savePersistedSessions(sessions);
spawnSessionPty(socket, sessionId, worktreePath, expandedConfig, sessionUuid, true, mcpConfigPath || undefined, expandedProjectDir, sessionName);
socket.emit("session-created", sessionId, persistedSession);
} catch (error) {
console.error("Error creating session:", error);
socket.emit("session-error", error instanceof Error ? error.message : String(error));
}
});
socket.on("session-input", (sessionId: string, data: string) => {
const ptyProcess = activePtyProcesses.get(sessionId);
if (ptyProcess) {
ptyProcess.write(data);
}
});
socket.on("session-resize", (sessionId: string, cols: number, rows: number) => {
const ptyProcess = activePtyProcesses.get(sessionId);
if (ptyProcess) {
try {
ptyProcess.resize(cols, rows);
} catch (error) {
console.error(`Error resizing session ${sessionId}:`, error);
// Remove dead PTY from map
activePtyProcesses.delete(sessionId);
}
}
});
socket.on("get-all-sessions", () => {
socket.emit("all-sessions", getPersistedSessions());
});
// Handle invoke requests (for async operations)
socket.on("invoke", async (requestId: number, channel: string, ...args: any[]) => {
try {
let result;
switch (channel) {
case "get-last-settings":
result = store.get("lastSessionConfig", {
projectDir: "",
parentBranch: "",
codingAgent: "claude",
skipPermissions: true,
});
break;
case "get-branches":
const dirPath = expandTilde(args[0]);
try {
const git = simpleGit(dirPath);
const branchSummary = await git.branch();
result = branchSummary.all;
} catch (error) {
result = [];
}
break;
case "select-directory":
// Web mode cannot open file dialogs - return null
result = null;
break;
case "get-terminal-settings":
result = store.get("terminalSettings");
break;
case "save-terminal-settings":
store.set("terminalSettings", args[0]);
result = null;
break;
case "list-mcp-servers":
// MCP not implemented in web mode yet
result = [];
break;
case "add-mcp-server":
case "remove-mcp-server":
case "get-mcp-server-details":
// MCP not implemented in web mode yet
result = null;
break;
default:
throw new Error(`Unknown invoke channel: ${channel}`);
}
socket.emit("invoke-response", requestId, null, result);
} catch (error) {
socket.emit("invoke-response", requestId, error instanceof Error ? error.message : String(error), null);
}
});
socket.on("save-settings", (config: SessionConfig) => {
store.set("lastSessionConfig", config);
});
socket.on("reopen-session", (sessionId: string) => {
if (activePtyProcesses.has(sessionId)) {
console.log(`[${sessionId}] Reconnecting existing PTY to new socket`);
// Add this socket to the session's socket set
if (!sessionSockets.has(sessionId)) {
sessionSockets.set(sessionId, new Set());
}
sessionSockets.get(sessionId)!.add(socket);
socket.emit("session-reopened", sessionId);
// Send a newline to trigger fresh output
const ptyProcess = activePtyProcesses.get(sessionId);
if (ptyProcess) {
console.log(`[${sessionId}] Sending newline to trigger output`);
ptyProcess.write("\r");
}
return;
}
const sessions = getPersistedSessions();
const session = sessions.find(s => s.id === sessionId);
if (!session) {
console.error("Session not found:", sessionId);
socket.emit("session-error", `Session ${sessionId} not found`);
return;
}
try {
spawnSessionPty(socket, sessionId, session.worktreePath, session.config, session.sessionUuid, false, session.mcpConfigPath, session.config.projectDir, session.name);
socket.emit("session-reopened", sessionId);
} catch (error) {
console.error("Error reopening session:", error);
socket.emit("session-error", error instanceof Error ? error.message : String(error));
}
});
socket.on("close-session", (sessionId: string) => {
const ptyProcess = activePtyProcesses.get(sessionId);
if (ptyProcess) {
// Just kill the PTY connection, tmux session keeps running
ptyProcess.kill();
activePtyProcesses.delete(sessionId);
console.log(`[${sessionId}] Detached from tmux session (session still running)`);
}
const mcpPoller = mcpPollerPtyProcesses.get(sessionId);
if (mcpPoller) {
mcpPoller.kill();
mcpPollerPtyProcesses.delete(sessionId);
}
});
socket.on("delete-session", async (sessionId: string) => {
const ptyProcess = activePtyProcesses.get(sessionId);
if (ptyProcess) {
ptyProcess.kill();
activePtyProcesses.delete(sessionId);
}
const mcpPoller = mcpPollerPtyProcesses.get(sessionId);
if (mcpPoller) {
mcpPoller.kill();
mcpPollerPtyProcesses.delete(sessionId);
}
const sessions = getPersistedSessions();
const sessionIndex = sessions.findIndex(s => s.id === sessionId);
if (sessionIndex === -1) {
console.error("Session not found:", sessionId);
return;
}
const session = sessions[sessionIndex];
// Kill the tmux session using the session name
const safeName = session.name.replace(/[^a-zA-Z0-9-_]/g, '-').toLowerCase();
const tmuxSessionName = `fleetcode-${safeName}`;
try {
require('child_process').execSync(`tmux kill-session -t ${tmuxSessionName} 2>&1`);
console.log(`[${sessionId}] Killed tmux session: ${tmuxSessionName}`);
} catch (error) {
// Session might not exist, that's okay
}
await removeWorktree(session.config.projectDir, session.worktreePath);
if (session.gitBranch) {
await removeGitBranch(session.config.projectDir, session.gitBranch);
}
sessions.splice(sessionIndex, 1);
savePersistedSessions(sessions);
socket.emit("session-deleted", sessionId);
});
socket.on("rename-session", (sessionId: string, newName: string) => {
const sessions = getPersistedSessions();
const session = sessions.find(s => s.id === sessionId);
if (session) {
session.name = newName;
savePersistedSessions(sessions);
}
});
socket.on("disconnect", () => {
// Clean up socket references for this client
// Note: We don't kill the PTYs - they keep running for reconnection
for (const [sessionId, sockets] of sessionSockets.entries()) {
if (sockets.has(socket)) {
sockets.delete(socket);
console.log(`[${sessionId}] Socket disconnected, ${sockets.size} remaining`);
// If no more sockets connected to this session, clean up the set
if (sockets.size === 0) {
sessionSockets.delete(sessionId);
}
}
}
});
});
function spawnSessionPty(
socket: any,
sessionId: string,
worktreePath: string,
config: SessionConfig,
sessionUuid: string,
isNewSession: boolean,
mcpConfigPath?: string,
projectDir?: string,
sessionName?: string
) {
// Use tmux for session persistence and resilience
// Use session name (sanitized) or fall back to sessionId
const safeName = (sessionName || sessionId)
.replace(/[^a-zA-Z0-9-_]/g, '-')
.toLowerCase();
const tmuxSessionName = `fleetcode-${safeName}`;
// Use zsh (check which shell is available)
let shell = "zsh";
try {
require('child_process').execSync('which zsh');
} catch {
shell = "bash";
}
let ptyProcess: pty.IPty;
// Check if tmux session already exists
let sessionExists = false;
try {
require('child_process').execSync(`tmux has-session -t ${tmuxSessionName} 2>&1`);
sessionExists = true;
} catch {
sessionExists = false;
}
if (sessionExists) {
console.log(`[${sessionId}] Attaching to existing tmux session: ${tmuxSessionName}`);
// Session exists, attach to it
ptyProcess = pty.spawn("tmux", ["attach-session", "-t", tmuxSessionName], {
name: "xterm-256color",
cols: 80,
rows: 30,
cwd: worktreePath,
env: { ...process.env, TERM: "xterm-256color" },
});
} else {
// Session doesn't exist, create new one
console.log(`[${sessionId}] Creating new tmux session: ${tmuxSessionName} with ${shell}`);
// For zsh, use -l (login) to source .zshrc and load direnv
// For bash, use -l as well
const shellArgs = shell === "zsh" ? ["-l"] : ["-l"];
ptyProcess = pty.spawn("tmux", [
"new-session",
"-s", tmuxSessionName,
"-c", worktreePath,
shell,
...shellArgs
], {
name: "xterm-256color",
cols: 80,
rows: 30,
cwd: worktreePath,
env: { ...process.env, TERM: "xterm-256color" },
});
}
activePtyProcesses.set(sessionId, ptyProcess);
// Initialize socket set for this session
if (!sessionSockets.has(sessionId)) {
sessionSockets.set(sessionId, new Set());
}
sessionSockets.get(sessionId)!.add(socket);
// Only run initialization if this is a NEW tmux session
// If we're attaching to existing, skip all setup (it's already running)
const shouldInitialize = !sessionExists;
let terminalReady = !shouldInitialize; // Skip setup if attaching to existing
let setupCommandsIdx = 0;
let dataBuffer = "";
let direnvAllowed = false;
ptyProcess.onData((data) => {
// Emit to all connected sockets for this session
const sockets = sessionSockets.get(sessionId);
if (sockets && sockets.size > 0) {
sockets.forEach(sock => {
sock.emit("session-output", sessionId, data);
});
} else {
console.log(`[${sessionId}] Warning: PTY output but no sockets connected`);
}
if (!terminalReady) {
dataBuffer += data;
if (isTerminalReady(dataBuffer, 0)) {
// First, allow direnv if .envrc exists
if (!direnvAllowed && fs.existsSync(path.join(worktreePath, ".envrc"))) {
console.log(`[${sessionId}] Allowing direnv for worktree`);
ptyProcess.write("direnv allow\r");
direnvAllowed = true;
} else if (config.setupCommands && setupCommandsIdx < config.setupCommands.length) {
const cmd = config.setupCommands[setupCommandsIdx];
ptyProcess.write(cmd + "\r");
setupCommandsIdx++;
} else {
terminalReady = true;
if (config.codingAgent === "claude") {
const sessionFlag = isNewSession
? `--session-id ${sessionUuid}`
: `--resume ${sessionUuid}`;
const skipPermissionsFlag = config.skipPermissions ? "--dangerously-skip-permissions" : "";
const mcpConfigFlag = mcpConfigPath ? `--mcp-config ${mcpConfigPath}` : "";
const flags = [sessionFlag, skipPermissionsFlag, mcpConfigFlag].filter(f => f).join(" ");
const claudeCmd = `claude ${flags}`;
ptyProcess.write(claudeCmd + "\r");
} else if (config.codingAgent === "codex") {
ptyProcess.write("codex\r");
} else if (config.codingAgent === "amp") {
ptyProcess.write("nix shell nixpkgs#nodejs -c npx -y @sourcegraph/amp\r");
}
}
}
}
});
return ptyProcess;
}
// Start server
const port = typeof PORT === 'string' ? parseInt(PORT, 10) : PORT;
httpServer.listen(port, "0.0.0.0", () => {
console.log(`🚀 FleetCode server running on http://0.0.0.0:${port}`);
console.log(`📡 Access from network: http://<your-ip>:${port}`);
});

View file

@ -53,7 +53,7 @@
/* Session List Item */
.session-list-item {
@apply mb-3 px-3 py-2 rounded cursor-pointer hover:bg-gray-700 text-gray-300 text-sm flex items-center justify-between;
@apply px-3 py-2 rounded cursor-pointer hover:bg-gray-700 text-gray-300 text-sm flex items-center justify-between;
}
.session-list-item.active {
@ -74,7 +74,7 @@
}
.session-delete-btn {
@apply text-gray-500 hover:text-red-500 ml-2;
@apply text-gray-500 hover:text-red-500;
opacity: 0;
transition: opacity 0.2s;
}
@ -113,25 +113,15 @@
@apply bg-gray-800 border-b-2 border-blue-500;
}
.unread-indicator {
display: none;
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 6px;
}
.tab.unread .unread-indicator {
.tab.unread .tab-name::after {
content: '';
display: inline-block;
width: 6px;
height: 6px;
background-color: #3b82f6;
}
.tab.activity .unread-indicator {
display: inline-block;
border: 2px solid #eab308;
border-top-color: transparent;
background-color: transparent;
animation: spin 1s linear infinite;
border-radius: 50%;
margin-left: 6px;
vertical-align: middle;
}
.tab-name {
@ -230,110 +220,4 @@
.btn-primary.loading .loading-spinner {
@apply border-white border-t-transparent;
}
/* Mobile Menu */
.mobile-menu-btn {
@apply fixed top-4 left-4 z-50 bg-gray-800 text-white p-2 rounded shadow-lg;
@apply flex flex-col justify-center items-center w-10 h-10;
@apply hover:bg-gray-700 transition-colors;
display: none;
}
.hamburger-line {
@apply bg-white w-6 h-0.5 mb-1 last:mb-0 transition-all;
}
.mobile-menu-btn.active .hamburger-line:nth-child(1) {
transform: translateY(6px) rotate(45deg);
}
.mobile-menu-btn.active .hamburger-line:nth-child(2) {
opacity: 0;
}
.mobile-menu-btn.active .hamburger-line:nth-child(3) {
transform: translateY(-6px) rotate(-45deg);
}
.mobile-overlay {
@apply fixed inset-0 bg-black bg-opacity-50 z-40 hidden;
}
.mobile-overlay.active {
display: block;
}
/* Mobile Responsive Styles */
@media (max-width: 768px) {
.mobile-menu-btn {
display: flex;
}
.sidebar {
@apply fixed top-0 left-0 bottom-0 z-50 transform -translate-x-full transition-transform duration-300;
width: 80vw;
max-width: 300px;
}
.sidebar.mobile-open {
@apply translate-x-0;
}
.tabs-container {
@apply text-xs;
padding-left: 50px; /* Space for hamburger menu */
}
.tab {
@apply px-2 py-1.5 text-xs;
}
.tab-name {
@apply text-xs;
}
.modal {
@apply max-w-[90vw] mx-4;
}
/* Make modals scrollable on mobile */
.modal-overlay {
@apply items-start pt-4 overflow-y-auto;
}
}
/* Tablet */
@media (min-width: 769px) and (max-width: 1024px) {
.sidebar {
@apply w-48;
}
.tab {
@apply px-3 py-1.5 text-sm;
}
}
/* Small mobile devices */
@media (max-width: 480px) {
.sidebar {
width: 85vw;
}
.sidebar-title {
@apply text-base;
}
.session-list-item {
@apply text-xs py-1.5;
}
.form-input,
.form-select {
@apply text-sm;
}
.modal-title {
@apply text-lg;
}
}
}

View file

@ -1,20 +0,0 @@
// Terminal escape sequences used for detecting terminal state
// Bracketed paste mode enable - indicates terminal is ready for input
export const BRACKETED_PASTE_MODE_ENABLE = "\x1b[?2004h";
// Pattern that indicates Claude interactive session is done and waiting for input
// Looks for: >\r\n (empty prompt with no space, no suggestion text)
const CLAUDE_READY_PROMPT_PATTERN = />\r\n/;
// Check if a normal shell terminal is ready for input
// Used during terminal initialization in main.ts
export function isTerminalReady(buffer: string, startPos: number = 0): boolean {
return buffer.includes(BRACKETED_PASTE_MODE_ENABLE, startPos);
}
// Check if Claude interactive session is done and ready for input
// Used for unread indicator detection in renderer.ts
export function isClaudeSessionReady(buffer: string): boolean {
return CLAUDE_READY_PROMPT_PATTERN.test(buffer);
}