Add Nix flake, development scripts, and web server setup documentation
Amp-Thread-ID: https://ampcode.com/threads/T-a6cf4bd2-36d0-4ef8-ada6-a8e2850fcacf Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
parent
158b2717d4
commit
df06be7bc7
43
.nixos-patch-electron.sh
Normal file
43
.nixos-patch-electron.sh
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
#!/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!"
|
||||
172
AMP_INTEGRATION.md
Normal file
172
AMP_INTEGRATION.md
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
# 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! 🚀
|
||||
117
NIX_SETUP.md
Normal file
117
NIX_SETUP.md
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
# 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!
|
||||
```
|
||||
|
|
@ -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) in parallel
|
||||
- **Multiple Sessions**: Run multiple coding agent sessions (Claude, Codex, Amp) 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`) or Codex
|
||||
- Claude CLI (`npm install -g @anthropic-ai/claude-cli`), Codex, or Amp
|
||||
|
||||
## 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 or Codex)
|
||||
3. Select your coding agent (Claude, Codex, or Amp)
|
||||
4. Optionally add setup commands (e.g., environment variables, source files)
|
||||
5. FleetCode creates a new git worktree and spawns a terminal session
|
||||
|
||||
|
|
|
|||
111
START_SERVER.md
Normal file
111
START_SERVER.md
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
# 🚀 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! 🎉
|
||||
181
WEB_SERVER_SETUP.md
Normal file
181
WEB_SERVER_SETUP.md
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
# 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
Executable file
36
dev.sh
Executable file
|
|
@ -0,0 +1,36 @@
|
|||
#!/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 .
|
||||
61
flake.lock
Normal file
61
flake.lock
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
{
|
||||
"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
Normal file
130
flake.nix
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
{
|
||||
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/"
|
||||
'';
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
32
index.html
32
index.html
|
|
@ -2,9 +2,12 @@
|
|||
<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;
|
||||
|
|
@ -14,9 +17,19 @@
|
|||
</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">
|
||||
<div class="sidebar" id="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h1 class="sidebar-title">FleetCode</h1>
|
||||
<button id="open-settings" class="settings-btn" title="Settings">⚙️</button>
|
||||
|
|
@ -82,6 +95,7 @@
|
|||
<select id="coding-agent" class="form-select">
|
||||
<option value="claude">Claude</option>
|
||||
<option value="codex">Codex</option>
|
||||
<option value="amp">Amp</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
|
@ -243,7 +257,21 @@
|
|||
</div>
|
||||
|
||||
<script>
|
||||
require('./dist/renderer.js');
|
||||
// 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);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
98
ipc-adapter.ts
Normal file
98
ipc-adapter.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
// 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();
|
||||
2
main.ts
2
main.ts
|
|
@ -293,6 +293,8 @@ 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
10
nodemon.json
Normal file
10
nodemon.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"watch": ["dist"],
|
||||
"ext": "js",
|
||||
"delay": "500",
|
||||
"verbose": false,
|
||||
"ignore": ["dist/styles.css"],
|
||||
"execMap": {
|
||||
"js": "node"
|
||||
}
|
||||
}
|
||||
1758
package-lock.json
generated
1758
package-lock.json
generated
File diff suppressed because it is too large
Load diff
18
package.json
18
package.json
|
|
@ -6,9 +6,18 @@
|
|||
"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"
|
||||
|
|
@ -23,19 +32,28 @@
|
|||
"@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"
|
||||
},
|
||||
|
|
|
|||
195
renderer.ts
195
renderer.ts
|
|
@ -1,8 +1,8 @@
|
|||
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;
|
||||
|
|
@ -198,13 +198,28 @@ 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: require("path").join(require("os").homedir(), "worktrees"),
|
||||
worktreeDir: getDefaultWorktreeDir(),
|
||||
};
|
||||
|
||||
const sessions = new Map<string, Session>();
|
||||
|
|
@ -241,7 +256,7 @@ function createTerminalUI(sessionId: string) {
|
|||
term.open(sessionElement);
|
||||
|
||||
term.onData((data) => {
|
||||
ipcRenderer.send("session-input", sessionId, data);
|
||||
ipcAdapter.send("session-input", sessionId, data);
|
||||
});
|
||||
|
||||
// Listen for bell character to mark unread activity
|
||||
|
|
@ -256,7 +271,7 @@ function createTerminalUI(sessionId: string) {
|
|||
const proposedDimensions = fitAddon.proposeDimensions();
|
||||
if (proposedDimensions) {
|
||||
fitAddon.fit();
|
||||
ipcRenderer.send("session-resize", sessionId, proposedDimensions.cols, proposedDimensions.rows);
|
||||
ipcAdapter.send("session-resize", sessionId, proposedDimensions.cols, proposedDimensions.rows);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -292,7 +307,9 @@ 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) {
|
||||
|
|
@ -300,6 +317,12 @@ 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;
|
||||
|
|
@ -417,7 +440,7 @@ function finishEditingSessionName(sessionId: string) {
|
|||
}
|
||||
|
||||
// Save to backend
|
||||
ipcRenderer.send("rename-session", sessionId, newName);
|
||||
ipcAdapter.send("rename-session", sessionId, newName);
|
||||
}
|
||||
|
||||
nameInput.classList.add("hidden");
|
||||
|
|
@ -441,14 +464,16 @@ 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
|
||||
ipcRenderer.send("reopen-session", sessionId);
|
||||
ipcAdapter.send("reopen-session", sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -557,6 +582,7 @@ 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");
|
||||
|
|
@ -612,7 +638,7 @@ function closeSession(sessionId: string) {
|
|||
updateSessionState(sessionId, false);
|
||||
|
||||
// Close PTY in main process
|
||||
ipcRenderer.send("close-session", sessionId);
|
||||
ipcAdapter.send("close-session", sessionId);
|
||||
|
||||
// Switch to another active session
|
||||
if (activeSessionId === sessionId) {
|
||||
|
|
@ -653,7 +679,7 @@ function deleteSession(sessionId: string) {
|
|||
sessions.delete(sessionId);
|
||||
|
||||
// Delete in main process (handles worktree removal)
|
||||
ipcRenderer.send("delete-session", sessionId);
|
||||
ipcAdapter.send("delete-session", sessionId);
|
||||
|
||||
// Switch to another session
|
||||
if (activeSessionId === sessionId) {
|
||||
|
|
@ -672,7 +698,7 @@ function deleteSession(sessionId: string) {
|
|||
}
|
||||
|
||||
// Handle session output
|
||||
ipcRenderer.on("session-output", (_event, sessionId: string, data: string) => {
|
||||
ipcAdapter.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
|
||||
|
|
@ -706,7 +732,7 @@ ipcRenderer.on("session-output", (_event, sessionId: string, data: string) => {
|
|||
});
|
||||
|
||||
// Handle session created
|
||||
ipcRenderer.on("session-created", (_event, sessionId: string, persistedSession: any) => {
|
||||
ipcAdapter.on("session-created", (_event, sessionId: string, persistedSession: any) => {
|
||||
const session = addSession(persistedSession, true);
|
||||
activateSession(sessionId);
|
||||
|
||||
|
|
@ -739,12 +765,12 @@ ipcRenderer.on("session-created", (_event, sessionId: string, persistedSession:
|
|||
});
|
||||
|
||||
// Handle session reopened
|
||||
ipcRenderer.on("session-reopened", (_event, sessionId: string) => {
|
||||
ipcAdapter.on("session-reopened", (_event, sessionId: string) => {
|
||||
activateSession(sessionId);
|
||||
});
|
||||
|
||||
// Handle session deleted
|
||||
ipcRenderer.on("session-deleted", (_event, sessionId: string) => {
|
||||
ipcAdapter.on("session-deleted", (_event, sessionId: string) => {
|
||||
const session = sessions.get(sessionId);
|
||||
if (session) {
|
||||
if (session.element) session.element.remove();
|
||||
|
|
@ -765,7 +791,7 @@ ipcRenderer.on("session-deleted", (_event, sessionId: string) => {
|
|||
});
|
||||
|
||||
// Load persisted sessions on startup
|
||||
ipcRenderer.on("load-persisted-sessions", (_event, persistedSessions: PersistedSession[]) => {
|
||||
ipcAdapter.on("load-persisted-sessions", (_event, persistedSessions: PersistedSession[]) => {
|
||||
persistedSessions.forEach(ps => {
|
||||
addSession(ps, false);
|
||||
});
|
||||
|
|
@ -798,14 +824,14 @@ document.getElementById("new-session")?.addEventListener("click", async () => {
|
|||
modal?.classList.remove("hidden");
|
||||
|
||||
// Load last used settings
|
||||
const lastSettings = await ipcRenderer.invoke("get-last-settings");
|
||||
const lastSettings = await ipcAdapter.invoke("get-last-settings");
|
||||
|
||||
if (lastSettings.projectDir) {
|
||||
selectedDirectory = lastSettings.projectDir;
|
||||
projectDirInput.value = lastSettings.projectDir;
|
||||
|
||||
// Load git branches for the last directory
|
||||
const branches = await ipcRenderer.invoke("get-branches", lastSettings.projectDir);
|
||||
const branches = await ipcAdapter.invoke("get-branches", lastSettings.projectDir);
|
||||
parentBranchSelect.innerHTML = "";
|
||||
|
||||
if (branches.length === 0) {
|
||||
|
|
@ -840,22 +866,63 @@ document.getElementById("new-session")?.addEventListener("click", async () => {
|
|||
}
|
||||
|
||||
// Show/hide skip permissions based on coding agent
|
||||
if (lastSettings.codingAgent === "codex") {
|
||||
if (lastSettings.codingAgent === "codex" || lastSettings.codingAgent === "amp") {
|
||||
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 () => {
|
||||
const dir = await ipcRenderer.invoke("select-directory");
|
||||
// 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");
|
||||
if (dir) {
|
||||
selectedDirectory = dir;
|
||||
projectDirInput.value = dir;
|
||||
|
||||
// Load git branches
|
||||
const branches = await ipcRenderer.invoke("get-branches", dir);
|
||||
const branches = await ipcAdapter.invoke("get-branches", dir);
|
||||
parentBranchSelect.innerHTML = "";
|
||||
|
||||
if (branches.length === 0) {
|
||||
|
|
@ -881,8 +948,16 @@ cancelBtn?.addEventListener("click", () => {
|
|||
|
||||
// Create session button
|
||||
createBtn?.addEventListener("click", () => {
|
||||
if (!selectedDirectory) {
|
||||
alert("Please select a project directory");
|
||||
// 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");
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -896,7 +971,7 @@ createBtn?.addEventListener("click", () => {
|
|||
const branchName = branchNameInput?.value.trim() || undefined;
|
||||
|
||||
const config: SessionConfig = {
|
||||
projectDir: selectedDirectory,
|
||||
projectDir: projectDir,
|
||||
parentBranch: parentBranchSelect.value,
|
||||
branchName,
|
||||
codingAgent: codingAgentSelect.value,
|
||||
|
|
@ -912,10 +987,10 @@ createBtn?.addEventListener("click", () => {
|
|||
}
|
||||
|
||||
// Save settings for next time
|
||||
ipcRenderer.send("save-settings", config);
|
||||
ipcAdapter.send("save-settings", config);
|
||||
|
||||
// Create the session
|
||||
ipcRenderer.send("create-session", config);
|
||||
ipcAdapter.send("create-session", config);
|
||||
});
|
||||
|
||||
// MCP Server management functions
|
||||
|
|
@ -926,7 +1001,7 @@ async function loadMcpServers() {
|
|||
}
|
||||
|
||||
try {
|
||||
await ipcRenderer.invoke("list-mcp-servers", activeSessionId);
|
||||
await ipcAdapter.invoke("list-mcp-servers", activeSessionId);
|
||||
// Results will come via mcp-servers-updated event
|
||||
} catch (error) {
|
||||
console.error("Failed to load MCP servers:", error);
|
||||
|
|
@ -948,7 +1023,7 @@ async function refreshMcpServers() {
|
|||
|
||||
try {
|
||||
// Trigger MCP list command
|
||||
await ipcRenderer.invoke("list-mcp-servers", activeSessionId);
|
||||
await ipcAdapter.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) {
|
||||
|
|
@ -993,7 +1068,7 @@ function renderMcpServers() {
|
|||
renderMcpServers();
|
||||
|
||||
try {
|
||||
await ipcRenderer.invoke("remove-mcp-server", server.name);
|
||||
await ipcAdapter.invoke("remove-mcp-server", server.name);
|
||||
} catch (error) {
|
||||
alert(`Failed to remove server: ${error}`);
|
||||
// Refresh to restore correct state on error
|
||||
|
|
@ -1023,7 +1098,7 @@ async function showMcpServerDetails(name: string) {
|
|||
detailsModal?.classList.remove("hidden");
|
||||
|
||||
try {
|
||||
const details = await ipcRenderer.invoke("get-mcp-server-details", name);
|
||||
const details = await ipcAdapter.invoke("get-mcp-server-details", name);
|
||||
|
||||
if (detailsContent) {
|
||||
let html = "";
|
||||
|
|
@ -1184,7 +1259,7 @@ addMcpBtn?.addEventListener("click", async () => {
|
|||
addMcpBtn.classList.add("opacity-50", "cursor-not-allowed");
|
||||
|
||||
try {
|
||||
await ipcRenderer.invoke("add-mcp-server", name, config);
|
||||
await ipcAdapter.invoke("add-mcp-server", name, config);
|
||||
mcpModal?.classList.add("hidden");
|
||||
// Force immediate refresh of MCP servers
|
||||
await refreshMcpServers();
|
||||
|
|
@ -1221,7 +1296,7 @@ removeMcpDetailsBtn?.addEventListener("click", async () => {
|
|||
renderMcpServers();
|
||||
|
||||
try {
|
||||
await ipcRenderer.invoke("remove-mcp-server", serverName);
|
||||
await ipcAdapter.invoke("remove-mcp-server", serverName);
|
||||
} catch (error) {
|
||||
alert(`Failed to remove server: ${error}`);
|
||||
// Refresh to restore correct state on error
|
||||
|
|
@ -1231,7 +1306,7 @@ removeMcpDetailsBtn?.addEventListener("click", async () => {
|
|||
});
|
||||
|
||||
// Listen for MCP polling started event
|
||||
ipcRenderer.on("mcp-polling-started", (_event, sessionId: string) => {
|
||||
ipcAdapter.on("mcp-polling-started", (_event, sessionId: string) => {
|
||||
if (sessionId === activeSessionId) {
|
||||
const addMcpServerBtn = document.getElementById("add-mcp-server");
|
||||
if (addMcpServerBtn) {
|
||||
|
|
@ -1242,7 +1317,7 @@ ipcRenderer.on("mcp-polling-started", (_event, sessionId: string) => {
|
|||
});
|
||||
|
||||
// Listen for MCP server updates from main process
|
||||
ipcRenderer.on("mcp-servers-updated", (_event, sessionId: string, servers: McpServer[]) => {
|
||||
ipcAdapter.on("mcp-servers-updated", (_event, sessionId: string, servers: McpServer[]) => {
|
||||
// Only update if this is for the active session
|
||||
if (sessionId === activeSessionId) {
|
||||
mcpServers = servers;
|
||||
|
|
@ -1273,7 +1348,7 @@ const browseWorktreeDirBtn = document.getElementById("browse-worktree-dir");
|
|||
|
||||
// Load saved settings on startup
|
||||
async function loadSettings() {
|
||||
const savedSettings = await ipcRenderer.invoke("get-terminal-settings");
|
||||
const savedSettings = await ipcAdapter.invoke("get-terminal-settings");
|
||||
if (savedSettings) {
|
||||
terminalSettings = { ...DEFAULT_SETTINGS, ...savedSettings };
|
||||
}
|
||||
|
|
@ -1335,9 +1410,21 @@ 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 () => {
|
||||
const dir = await ipcRenderer.invoke("select-directory");
|
||||
// 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");
|
||||
if (dir) {
|
||||
settingsWorktreeDir.value = dir;
|
||||
}
|
||||
|
|
@ -1353,7 +1440,7 @@ saveSettingsBtn?.addEventListener("click", async () => {
|
|||
terminalSettings.worktreeDir = settingsWorktreeDir.value || DEFAULT_SETTINGS.worktreeDir;
|
||||
|
||||
// Save to electron-store
|
||||
await ipcRenderer.invoke("save-terminal-settings", terminalSettings);
|
||||
await ipcAdapter.invoke("save-terminal-settings", terminalSettings);
|
||||
|
||||
// Apply to all existing terminals
|
||||
applySettingsToAllTerminals();
|
||||
|
|
@ -1364,3 +1451,39 @@ 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
Normal file
655
server.ts
Normal file
|
|
@ -0,0 +1,655 @@
|
|||
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}`);
|
||||
});
|
||||
106
styles.css
106
styles.css
|
|
@ -230,4 +230,110 @@
|
|||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue