Compare commits
11 commits
d6f853a497
...
df06be7bc7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df06be7bc7 | ||
|
|
158b2717d4 | ||
|
|
96caeea42a | ||
|
|
3abc2e2f96 | ||
|
|
67f5162c3a | ||
|
|
12d91912cd | ||
|
|
c431f8ff9d | ||
|
|
74bcedf375 | ||
|
|
d19fc88992 | ||
|
|
26ab4531fa | ||
|
|
b0ab86ba9a |
45
.github/workflows/release.yml
vendored
45
.github/workflows/release.yml
vendored
|
|
@ -9,7 +9,50 @@ permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
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
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
|
|
|
||||||
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
|
## 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
|
- **Git Worktree Isolation**: Each session runs in its own git worktree, keeping work isolated
|
||||||
- **Persistent Sessions**: Sessions persist across app restarts with automatic resumption
|
- **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)
|
- **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+
|
- Node.js 16+
|
||||||
- Git
|
- 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
|
## Installation
|
||||||
|
|
||||||
|
|
@ -47,7 +47,7 @@ npm start
|
||||||
|
|
||||||
1. Select a project directory (must be a git repository)
|
1. Select a project directory (must be a git repository)
|
||||||
2. Choose a parent branch for the worktree
|
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)
|
4. Optionally add setup commands (e.g., environment variables, source files)
|
||||||
5. FleetCode creates a new git worktree and spawns a terminal session
|
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>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<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>
|
<title>FleetCode Terminal</title>
|
||||||
<link rel="stylesheet" href="node_modules/xterm/css/xterm.css" />
|
<link rel="stylesheet" href="node_modules/xterm/css/xterm.css" />
|
||||||
<link rel="stylesheet" href="dist/styles.css" />
|
<link rel="stylesheet" href="dist/styles.css" />
|
||||||
|
<!-- Socket.IO client for web mode -->
|
||||||
|
<script src="/socket.io/socket.io.js"></script>
|
||||||
<style>
|
<style>
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
@ -14,9 +17,19 @@
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="h-screen overflow-hidden bg-gray-900">
|
<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">
|
<div class="flex h-full">
|
||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
<div class="sidebar">
|
<div class="sidebar" id="sidebar">
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
<h1 class="sidebar-title">FleetCode</h1>
|
<h1 class="sidebar-title">FleetCode</h1>
|
||||||
<button id="open-settings" class="settings-btn" title="Settings">⚙️</button>
|
<button id="open-settings" class="settings-btn" title="Settings">⚙️</button>
|
||||||
|
|
@ -82,6 +95,7 @@
|
||||||
<select id="coding-agent" class="form-select">
|
<select id="coding-agent" class="form-select">
|
||||||
<option value="claude">Claude</option>
|
<option value="claude">Claude</option>
|
||||||
<option value="codex">Codex</option>
|
<option value="codex">Codex</option>
|
||||||
|
<option value="amp">Amp</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -243,7 +257,21 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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();
|
||||||
18
main.ts
18
main.ts
|
|
@ -9,6 +9,7 @@ import {simpleGit} from "simple-git";
|
||||||
import {promisify} from "util";
|
import {promisify} from "util";
|
||||||
import {v4 as uuidv4} from "uuid";
|
import {v4 as uuidv4} from "uuid";
|
||||||
import {PersistedSession, SessionConfig} from "./types";
|
import {PersistedSession, SessionConfig} from "./types";
|
||||||
|
import {isTerminalReady} from "./terminal-utils";
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
|
@ -31,12 +32,6 @@ function getWorktreeBaseDir(): string {
|
||||||
return path.join(os.homedir(), "worktrees");
|
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[]) {
|
function savePersistedSessions(sessions: PersistedSession[]) {
|
||||||
(store as any).set("sessions", sessions);
|
(store as any).set("sessions", sessions);
|
||||||
}
|
}
|
||||||
|
|
@ -139,13 +134,14 @@ function spawnMcpPoller(sessionId: string, projectDir: string) {
|
||||||
const serverMap = new Map<string, any>();
|
const serverMap = new Map<string, any>();
|
||||||
|
|
||||||
ptyProcess.onData((data) => {
|
ptyProcess.onData((data) => {
|
||||||
|
|
||||||
// Accumulate output without displaying it
|
// Accumulate output without displaying it
|
||||||
outputBuffer += data;
|
outputBuffer += data;
|
||||||
|
|
||||||
// Parse output whenever we have MCP server entries
|
// Parse output whenever we have MCP server entries
|
||||||
// Match lines like: "servername: url (type) - ✓ Connected" or "servername: command (stdio) - ✓ Connected"
|
// Match lines like: "servername: url (type) - ✓ Connected" or "servername: command (stdio) - ✓ Connected"
|
||||||
// Pattern handles SSE, stdio, and HTTP types
|
// Pattern handles SSE, stdio, and HTTP types (case-insensitive) with success (✓), warning (⚠), or failure (✗) status
|
||||||
const mcpServerLineRegex = /^[\w-]+:.+\((?:SSE|stdio|HTTP)\)\s+-\s+[✓⚠]/m;
|
const mcpServerLineRegex = /^[\w-]+:.+\((?:SSE|sse|stdio|HTTP)\)\s+-\s+[✓⚠✗]/mi;
|
||||||
|
|
||||||
if (mcpServerLineRegex.test(data) || data.includes("No MCP servers configured")) {
|
if (mcpServerLineRegex.test(data) || data.includes("No MCP servers configured")) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -217,7 +213,7 @@ function parseMcpOutput(output: string): any[] {
|
||||||
|
|
||||||
// Only parse lines that match the MCP server format
|
// Only parse lines that match the MCP server format
|
||||||
// Must have: "name: something (SSE|stdio|HTTP) - status"
|
// Must have: "name: something (SSE|stdio|HTTP) - status"
|
||||||
const serverMatch = line.match(/^([\w-]+):.+\((?:SSE|stdio|HTTP)\)\s+-\s+[✓⚠]/);
|
const serverMatch = line.match(/^([\w-]+):.+\((?:SSE|sse|stdio|HTTP)\)\s+-\s+[✓⚠✗]/i);
|
||||||
if (serverMatch) {
|
if (serverMatch) {
|
||||||
const serverName = serverMatch[1];
|
const serverName = serverMatch[1];
|
||||||
const isConnected = line.includes("✓") || line.includes("Connected");
|
const isConnected = line.includes("✓") || line.includes("Connected");
|
||||||
|
|
@ -297,6 +293,8 @@ function spawnSessionPty(
|
||||||
}
|
}
|
||||||
} else if (config.codingAgent === "codex") {
|
} else if (config.codingAgent === "codex") {
|
||||||
ptyProcess.write("codex\r");
|
ptyProcess.write("codex\r");
|
||||||
|
} else if (config.codingAgent === "amp") {
|
||||||
|
ptyProcess.write("nix shell nixpkgs#nodejs -c npx -y @sourcegraph/amp\r");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -469,6 +467,7 @@ ipcMain.on("create-session", async (event, config: SessionConfig) => {
|
||||||
// Handle session input
|
// Handle session input
|
||||||
ipcMain.on("session-input", (_event, sessionId: string, data: string) => {
|
ipcMain.on("session-input", (_event, sessionId: string, data: string) => {
|
||||||
const ptyProcess = activePtyProcesses.get(sessionId);
|
const ptyProcess = activePtyProcesses.get(sessionId);
|
||||||
|
|
||||||
if (ptyProcess) {
|
if (ptyProcess) {
|
||||||
ptyProcess.write(data);
|
ptyProcess.write(data);
|
||||||
}
|
}
|
||||||
|
|
@ -508,6 +507,7 @@ ipcMain.on("reopen-session", (event, sessionId: string) => {
|
||||||
// Close session (kill PTY but keep session)
|
// Close session (kill PTY but keep session)
|
||||||
ipcMain.on("close-session", (_event, sessionId: string) => {
|
ipcMain.on("close-session", (_event, sessionId: string) => {
|
||||||
const ptyProcess = activePtyProcesses.get(sessionId);
|
const ptyProcess = activePtyProcesses.get(sessionId);
|
||||||
|
|
||||||
if (ptyProcess) {
|
if (ptyProcess) {
|
||||||
ptyProcess.kill();
|
ptyProcess.kill();
|
||||||
activePtyProcesses.delete(sessionId);
|
activePtyProcesses.delete(sessionId);
|
||||||
|
|
|
||||||
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
1762
package-lock.json
generated
1762
package-lock.json
generated
File diff suppressed because it is too large
Load diff
20
package.json
20
package.json
|
|
@ -1,14 +1,23 @@
|
||||||
{
|
{
|
||||||
"name": "fleetcode",
|
"name": "fleetcode",
|
||||||
"version": "1.0.1-beta.6",
|
"version": "1.0.1-beta.7",
|
||||||
"description": "Run multiple CLI coding agents at once",
|
"description": "Run multiple CLI coding agents at once",
|
||||||
"main": "dist/main.js",
|
"main": "dist/main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build:css": "npx tailwindcss -i styles.css -o dist/styles.css",
|
"build:css": "npx tailwindcss -i styles.css -o dist/styles.css",
|
||||||
"build:ts": "tsc",
|
"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": "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 .",
|
"start": "npm run build && electron .",
|
||||||
"dev": "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",
|
"pack": "npm run build && electron-builder --dir",
|
||||||
"dist": "npm run build && electron-builder",
|
"dist": "npm run build && electron-builder",
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
|
@ -23,19 +32,28 @@
|
||||||
"@types/node": "^24.6.2",
|
"@types/node": "^24.6.2",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
|
"concurrently": "^9.2.1",
|
||||||
"electron": "^38.2.0",
|
"electron": "^38.2.0",
|
||||||
"electron-builder": "^26.0.12",
|
"electron-builder": "^26.0.12",
|
||||||
"electron-rebuild": "^3.2.9",
|
"electron-rebuild": "^3.2.9",
|
||||||
|
"esbuild": "^0.25.10",
|
||||||
|
"nodemon": "^3.1.10",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"tailwindcss": "^3.4.18",
|
"tailwindcss": "^3.4.18",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@types/cors": "^2.8.19",
|
||||||
|
"@types/express": "^5.0.3",
|
||||||
"@xterm/addon-fit": "^0.10.0",
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
|
"cors": "^2.8.5",
|
||||||
"electron-store": "^11.0.0",
|
"electron-store": "^11.0.0",
|
||||||
|
"express": "^5.1.0",
|
||||||
"node-pty": "^1.0.0",
|
"node-pty": "^1.0.0",
|
||||||
"simple-git": "^3.28.0",
|
"simple-git": "^3.28.0",
|
||||||
|
"socket.io": "^4.8.1",
|
||||||
|
"socket.io-client": "^4.8.1",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
"xterm": "^5.3.0"
|
"xterm": "^5.3.0"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
308
renderer.ts
308
renderer.ts
|
|
@ -1,7 +1,8 @@
|
||||||
import {FitAddon} from "@xterm/addon-fit";
|
import {FitAddon} from "@xterm/addon-fit";
|
||||||
import {ipcRenderer} from "electron";
|
|
||||||
import {Terminal} from "xterm";
|
import {Terminal} from "xterm";
|
||||||
import {PersistedSession, SessionConfig} from "./types";
|
import {PersistedSession, SessionConfig} from "./types";
|
||||||
|
import {isClaudeSessionReady} from "./terminal-utils";
|
||||||
|
import {ipcAdapter} from "./ipc-adapter";
|
||||||
|
|
||||||
interface Session {
|
interface Session {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -12,7 +13,6 @@ interface Session {
|
||||||
config: SessionConfig;
|
config: SessionConfig;
|
||||||
worktreePath: string;
|
worktreePath: string;
|
||||||
hasActivePty: boolean;
|
hasActivePty: boolean;
|
||||||
hasUnreadActivity: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface McpServer {
|
interface McpServer {
|
||||||
|
|
@ -198,13 +198,28 @@ function getSystemTheme(): "light" | "dark" {
|
||||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
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
|
// Default settings - macOS Terminal matching system theme
|
||||||
const DEFAULT_SETTINGS: TerminalSettings = {
|
const DEFAULT_SETTINGS: TerminalSettings = {
|
||||||
fontFamily: "Menlo, Monaco, 'Courier New', monospace",
|
fontFamily: "Menlo, Monaco, 'Courier New', monospace",
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
theme: getSystemTheme() === "dark" ? "macos-dark" : "macos-light",
|
theme: getSystemTheme() === "dark" ? "macos-dark" : "macos-light",
|
||||||
cursorBlink: false,
|
cursorBlink: false,
|
||||||
worktreeDir: require("path").join(require("os").homedir(), "worktrees"),
|
worktreeDir: getDefaultWorktreeDir(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const sessions = new Map<string, Session>();
|
const sessions = new Map<string, Session>();
|
||||||
|
|
@ -213,6 +228,9 @@ let mcpServers: McpServer[] = [];
|
||||||
let mcpPollerActive = false;
|
let mcpPollerActive = false;
|
||||||
let terminalSettings: TerminalSettings = { ...DEFAULT_SETTINGS };
|
let terminalSettings: TerminalSettings = { ...DEFAULT_SETTINGS };
|
||||||
|
|
||||||
|
// Track activity timers for each session
|
||||||
|
const activityTimers = new Map<string, NodeJS.Timeout>();
|
||||||
|
|
||||||
function createTerminalUI(sessionId: string) {
|
function createTerminalUI(sessionId: string) {
|
||||||
const themeColors = THEME_PRESETS[terminalSettings.theme] || THEME_PRESETS["macos-dark"];
|
const themeColors = THEME_PRESETS[terminalSettings.theme] || THEME_PRESETS["macos-dark"];
|
||||||
|
|
||||||
|
|
@ -238,7 +256,7 @@ function createTerminalUI(sessionId: string) {
|
||||||
term.open(sessionElement);
|
term.open(sessionElement);
|
||||||
|
|
||||||
term.onData((data) => {
|
term.onData((data) => {
|
||||||
ipcRenderer.send("session-input", sessionId, data);
|
ipcAdapter.send("session-input", sessionId, data);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen for bell character to mark unread activity
|
// Listen for bell character to mark unread activity
|
||||||
|
|
@ -253,7 +271,7 @@ function createTerminalUI(sessionId: string) {
|
||||||
const proposedDimensions = fitAddon.proposeDimensions();
|
const proposedDimensions = fitAddon.proposeDimensions();
|
||||||
if (proposedDimensions) {
|
if (proposedDimensions) {
|
||||||
fitAddon.fit();
|
fitAddon.fit();
|
||||||
ipcRenderer.send("session-resize", sessionId, proposedDimensions.cols, proposedDimensions.rows);
|
ipcAdapter.send("session-resize", sessionId, proposedDimensions.cols, proposedDimensions.rows);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -272,7 +290,6 @@ function addSession(persistedSession: PersistedSession, hasActivePty: boolean) {
|
||||||
config: persistedSession.config,
|
config: persistedSession.config,
|
||||||
worktreePath: persistedSession.worktreePath,
|
worktreePath: persistedSession.worktreePath,
|
||||||
hasActivePty,
|
hasActivePty,
|
||||||
hasUnreadActivity: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
sessions.set(persistedSession.id, session);
|
sessions.set(persistedSession.id, session);
|
||||||
|
|
@ -290,7 +307,9 @@ function addSession(persistedSession: PersistedSession, hasActivePty: boolean) {
|
||||||
|
|
||||||
function activateSession(sessionId: string) {
|
function activateSession(sessionId: string) {
|
||||||
const session = sessions.get(sessionId);
|
const session = sessions.get(sessionId);
|
||||||
if (!session) return;
|
if (!session) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// If terminal UI doesn't exist yet, create it
|
// If terminal UI doesn't exist yet, create it
|
||||||
if (!session.terminal) {
|
if (!session.terminal) {
|
||||||
|
|
@ -298,6 +317,12 @@ function activateSession(sessionId: string) {
|
||||||
session.terminal = ui.terminal;
|
session.terminal = ui.terminal;
|
||||||
session.fitAddon = ui.fitAddon;
|
session.fitAddon = ui.fitAddon;
|
||||||
session.element = ui.element;
|
session.element = ui.element;
|
||||||
|
// Force initial fit
|
||||||
|
setTimeout(() => {
|
||||||
|
if (session.fitAddon) {
|
||||||
|
session.fitAddon.fit();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
session.hasActivePty = true;
|
session.hasActivePty = true;
|
||||||
|
|
@ -415,7 +440,7 @@ function finishEditingSessionName(sessionId: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save to backend
|
// Save to backend
|
||||||
ipcRenderer.send("rename-session", sessionId, newName);
|
ipcAdapter.send("rename-session", sessionId, newName);
|
||||||
}
|
}
|
||||||
|
|
||||||
nameInput.classList.add("hidden");
|
nameInput.classList.add("hidden");
|
||||||
|
|
@ -439,14 +464,16 @@ function cancelEditingSessionName(sessionId: string) {
|
||||||
|
|
||||||
function handleSessionClick(sessionId: string) {
|
function handleSessionClick(sessionId: string) {
|
||||||
const session = sessions.get(sessionId);
|
const session = sessions.get(sessionId);
|
||||||
if (!session) return;
|
if (!session) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (session.hasActivePty) {
|
if (session.hasActivePty) {
|
||||||
// Just switch to it
|
// Just switch to it
|
||||||
switchToSession(sessionId);
|
switchToSession(sessionId);
|
||||||
} else {
|
} else {
|
||||||
// Reopen the session
|
// Reopen the session
|
||||||
ipcRenderer.send("reopen-session", sessionId);
|
ipcAdapter.send("reopen-session", sessionId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -458,6 +485,7 @@ function addTab(sessionId: string, name: string) {
|
||||||
tab.id = `tab-${sessionId}`;
|
tab.id = `tab-${sessionId}`;
|
||||||
tab.className = "tab";
|
tab.className = "tab";
|
||||||
tab.innerHTML = `
|
tab.innerHTML = `
|
||||||
|
<span class="unread-indicator"></span>
|
||||||
<span class="tab-name">${name}</span>
|
<span class="tab-name">${name}</span>
|
||||||
<button class="tab-close-btn" data-id="${sessionId}">×</button>
|
<button class="tab-close-btn" data-id="${sessionId}">×</button>
|
||||||
`;
|
`;
|
||||||
|
|
@ -481,8 +509,6 @@ function markSessionAsUnread(sessionId: string) {
|
||||||
const session = sessions.get(sessionId);
|
const session = sessions.get(sessionId);
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
|
|
||||||
session.hasUnreadActivity = true;
|
|
||||||
|
|
||||||
// Add unread indicator to tab
|
// Add unread indicator to tab
|
||||||
const tab = document.getElementById(`tab-${sessionId}`);
|
const tab = document.getElementById(`tab-${sessionId}`);
|
||||||
if (tab) {
|
if (tab) {
|
||||||
|
|
@ -494,8 +520,6 @@ function clearUnreadStatus(sessionId: string) {
|
||||||
const session = sessions.get(sessionId);
|
const session = sessions.get(sessionId);
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
|
|
||||||
session.hasUnreadActivity = false;
|
|
||||||
|
|
||||||
// Remove unread indicator from tab
|
// Remove unread indicator from tab
|
||||||
const tab = document.getElementById(`tab-${sessionId}`);
|
const tab = document.getElementById(`tab-${sessionId}`);
|
||||||
if (tab) {
|
if (tab) {
|
||||||
|
|
@ -503,6 +527,49 @@ 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) {
|
function switchToSession(sessionId: string) {
|
||||||
// Hide all sessions
|
// Hide all sessions
|
||||||
sessions.forEach((session, id) => {
|
sessions.forEach((session, id) => {
|
||||||
|
|
@ -515,6 +582,7 @@ function switchToSession(sessionId: string) {
|
||||||
|
|
||||||
// Show active session
|
// Show active session
|
||||||
const session = sessions.get(sessionId);
|
const session = sessions.get(sessionId);
|
||||||
|
|
||||||
if (session && session.element && session.terminal && session.fitAddon) {
|
if (session && session.element && session.terminal && session.fitAddon) {
|
||||||
session.element.classList.add("active");
|
session.element.classList.add("active");
|
||||||
document.getElementById(`tab-${sessionId}`)?.classList.add("active");
|
document.getElementById(`tab-${sessionId}`)?.classList.add("active");
|
||||||
|
|
@ -534,15 +602,9 @@ function switchToSession(sessionId: string) {
|
||||||
// Load MCP servers for this session
|
// Load MCP servers for this session
|
||||||
loadMcpServers();
|
loadMcpServers();
|
||||||
|
|
||||||
// Clear unread status when switching to this session
|
// Clear unread and activity status when switching to this session
|
||||||
clearUnreadStatus(sessionId);
|
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
|
// Focus and resize
|
||||||
session.terminal.focus();
|
session.terminal.focus();
|
||||||
|
|
@ -575,15 +637,8 @@ function closeSession(sessionId: string) {
|
||||||
// Update UI indicator
|
// Update UI indicator
|
||||||
updateSessionState(sessionId, false);
|
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
|
// Close PTY in main process
|
||||||
ipcRenderer.send("close-session", sessionId);
|
ipcAdapter.send("close-session", sessionId);
|
||||||
|
|
||||||
// Switch to another active session
|
// Switch to another active session
|
||||||
if (activeSessionId === sessionId) {
|
if (activeSessionId === sessionId) {
|
||||||
|
|
@ -623,15 +678,8 @@ function deleteSession(sessionId: string) {
|
||||||
// Remove from sessions map
|
// Remove from sessions map
|
||||||
sessions.delete(sessionId);
|
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)
|
// Delete in main process (handles worktree removal)
|
||||||
ipcRenderer.send("delete-session", sessionId);
|
ipcAdapter.send("delete-session", sessionId);
|
||||||
|
|
||||||
// Switch to another session
|
// Switch to another session
|
||||||
if (activeSessionId === sessionId) {
|
if (activeSessionId === sessionId) {
|
||||||
|
|
@ -649,12 +697,8 @@ 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
|
// 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);
|
const session = sessions.get(sessionId);
|
||||||
if (session && session.terminal) {
|
if (session && session.terminal) {
|
||||||
// Filter out [3J (clear scrollback) to prevent viewport resets during interactive menus
|
// Filter out [3J (clear scrollback) to prevent viewport resets during interactive menus
|
||||||
|
|
@ -663,35 +707,32 @@ ipcRenderer.on("session-output", (_event, sessionId: string, data: string) => {
|
||||||
|
|
||||||
session.terminal.write(filteredData);
|
session.terminal.write(filteredData);
|
||||||
|
|
||||||
// Only mark as unread if this is not the active session
|
// Only mark as unread/activity if this is not the active session
|
||||||
if (activeSessionId !== sessionId && session.hasActivePty && !session.hasUnreadActivity) {
|
if (activeSessionId !== sessionId && session.hasActivePty) {
|
||||||
// Only track substantive output (ignore cursor movements, keepalives, etc)
|
// Show activity spinner while output is coming in
|
||||||
// Look for actual text content or common escape sequences that indicate real output
|
markSessionActivity(sessionId);
|
||||||
const hasSubstantiveOutput = /[a-zA-Z0-9]/.test(filteredData) ||
|
|
||||||
filteredData.includes('\n') ||
|
|
||||||
filteredData.includes('\r');
|
|
||||||
|
|
||||||
if (hasSubstantiveOutput) {
|
// Check if Claude session is ready for input
|
||||||
// Clear any existing idle timer
|
if (isClaudeSessionReady(filteredData)) {
|
||||||
const existingTimer = sessionIdleTimers.get(sessionId);
|
// Clear activity timer and set unread
|
||||||
|
const existingTimer = activityTimers.get(sessionId);
|
||||||
if (existingTimer) {
|
if (existingTimer) {
|
||||||
clearTimeout(existingTimer);
|
clearTimeout(existingTimer);
|
||||||
|
activityTimers.delete(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set a new timer - if no output for IDLE_DELAY_MS, mark as unread
|
const tab = document.getElementById(`tab-${sessionId}`);
|
||||||
const timer = setTimeout(() => {
|
if (tab) {
|
||||||
markSessionAsUnread(sessionId);
|
tab.classList.remove("activity");
|
||||||
sessionIdleTimers.delete(sessionId);
|
tab.classList.add("unread");
|
||||||
}, IDLE_DELAY_MS);
|
}
|
||||||
|
|
||||||
sessionIdleTimers.set(sessionId, timer);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle session created
|
// 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);
|
const session = addSession(persistedSession, true);
|
||||||
activateSession(sessionId);
|
activateSession(sessionId);
|
||||||
|
|
||||||
|
|
@ -724,12 +765,12 @@ ipcRenderer.on("session-created", (_event, sessionId: string, persistedSession:
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle session reopened
|
// Handle session reopened
|
||||||
ipcRenderer.on("session-reopened", (_event, sessionId: string) => {
|
ipcAdapter.on("session-reopened", (_event, sessionId: string) => {
|
||||||
activateSession(sessionId);
|
activateSession(sessionId);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle session deleted
|
// Handle session deleted
|
||||||
ipcRenderer.on("session-deleted", (_event, sessionId: string) => {
|
ipcAdapter.on("session-deleted", (_event, sessionId: string) => {
|
||||||
const session = sessions.get(sessionId);
|
const session = sessions.get(sessionId);
|
||||||
if (session) {
|
if (session) {
|
||||||
if (session.element) session.element.remove();
|
if (session.element) session.element.remove();
|
||||||
|
|
@ -750,7 +791,7 @@ ipcRenderer.on("session-deleted", (_event, sessionId: string) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load persisted sessions on startup
|
// Load persisted sessions on startup
|
||||||
ipcRenderer.on("load-persisted-sessions", (_event, persistedSessions: PersistedSession[]) => {
|
ipcAdapter.on("load-persisted-sessions", (_event, persistedSessions: PersistedSession[]) => {
|
||||||
persistedSessions.forEach(ps => {
|
persistedSessions.forEach(ps => {
|
||||||
addSession(ps, false);
|
addSession(ps, false);
|
||||||
});
|
});
|
||||||
|
|
@ -783,14 +824,14 @@ document.getElementById("new-session")?.addEventListener("click", async () => {
|
||||||
modal?.classList.remove("hidden");
|
modal?.classList.remove("hidden");
|
||||||
|
|
||||||
// Load last used settings
|
// Load last used settings
|
||||||
const lastSettings = await ipcRenderer.invoke("get-last-settings");
|
const lastSettings = await ipcAdapter.invoke("get-last-settings");
|
||||||
|
|
||||||
if (lastSettings.projectDir) {
|
if (lastSettings.projectDir) {
|
||||||
selectedDirectory = lastSettings.projectDir;
|
selectedDirectory = lastSettings.projectDir;
|
||||||
projectDirInput.value = lastSettings.projectDir;
|
projectDirInput.value = lastSettings.projectDir;
|
||||||
|
|
||||||
// Load git branches for the last directory
|
// 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 = "";
|
parentBranchSelect.innerHTML = "";
|
||||||
|
|
||||||
if (branches.length === 0) {
|
if (branches.length === 0) {
|
||||||
|
|
@ -825,22 +866,63 @@ document.getElementById("new-session")?.addEventListener("click", async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show/hide skip permissions based on coding agent
|
// Show/hide skip permissions based on coding agent
|
||||||
if (lastSettings.codingAgent === "codex") {
|
if (lastSettings.codingAgent === "codex" || lastSettings.codingAgent === "amp") {
|
||||||
skipPermissionsGroup?.classList.add("hidden");
|
skipPermissionsGroup?.classList.add("hidden");
|
||||||
} else {
|
} else {
|
||||||
skipPermissionsGroup?.classList.remove("hidden");
|
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
|
// Browse directory
|
||||||
browseDirBtn?.addEventListener("click", async () => {
|
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) {
|
if (dir) {
|
||||||
selectedDirectory = dir;
|
selectedDirectory = dir;
|
||||||
projectDirInput.value = dir;
|
projectDirInput.value = dir;
|
||||||
|
|
||||||
// Load git branches
|
// Load git branches
|
||||||
const branches = await ipcRenderer.invoke("get-branches", dir);
|
const branches = await ipcAdapter.invoke("get-branches", dir);
|
||||||
parentBranchSelect.innerHTML = "";
|
parentBranchSelect.innerHTML = "";
|
||||||
|
|
||||||
if (branches.length === 0) {
|
if (branches.length === 0) {
|
||||||
|
|
@ -866,8 +948,16 @@ cancelBtn?.addEventListener("click", () => {
|
||||||
|
|
||||||
// Create session button
|
// Create session button
|
||||||
createBtn?.addEventListener("click", () => {
|
createBtn?.addEventListener("click", () => {
|
||||||
if (!selectedDirectory) {
|
// In web mode, use the input value directly if selectedDirectory isn't set
|
||||||
alert("Please select a project directory");
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -881,7 +971,7 @@ createBtn?.addEventListener("click", () => {
|
||||||
const branchName = branchNameInput?.value.trim() || undefined;
|
const branchName = branchNameInput?.value.trim() || undefined;
|
||||||
|
|
||||||
const config: SessionConfig = {
|
const config: SessionConfig = {
|
||||||
projectDir: selectedDirectory,
|
projectDir: projectDir,
|
||||||
parentBranch: parentBranchSelect.value,
|
parentBranch: parentBranchSelect.value,
|
||||||
branchName,
|
branchName,
|
||||||
codingAgent: codingAgentSelect.value,
|
codingAgent: codingAgentSelect.value,
|
||||||
|
|
@ -897,10 +987,10 @@ createBtn?.addEventListener("click", () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save settings for next time
|
// Save settings for next time
|
||||||
ipcRenderer.send("save-settings", config);
|
ipcAdapter.send("save-settings", config);
|
||||||
|
|
||||||
// Create the session
|
// Create the session
|
||||||
ipcRenderer.send("create-session", config);
|
ipcAdapter.send("create-session", config);
|
||||||
});
|
});
|
||||||
|
|
||||||
// MCP Server management functions
|
// MCP Server management functions
|
||||||
|
|
@ -911,7 +1001,7 @@ async function loadMcpServers() {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await ipcRenderer.invoke("list-mcp-servers", activeSessionId);
|
await ipcAdapter.invoke("list-mcp-servers", activeSessionId);
|
||||||
// Results will come via mcp-servers-updated event
|
// Results will come via mcp-servers-updated event
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load MCP servers:", error);
|
console.error("Failed to load MCP servers:", error);
|
||||||
|
|
@ -933,7 +1023,7 @@ async function refreshMcpServers() {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Trigger MCP list command
|
// 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
|
// Wait a bit for the poller to process and send results
|
||||||
await new Promise(resolve => setTimeout(resolve, 500));
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -978,7 +1068,7 @@ function renderMcpServers() {
|
||||||
renderMcpServers();
|
renderMcpServers();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await ipcRenderer.invoke("remove-mcp-server", server.name);
|
await ipcAdapter.invoke("remove-mcp-server", server.name);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert(`Failed to remove server: ${error}`);
|
alert(`Failed to remove server: ${error}`);
|
||||||
// Refresh to restore correct state on error
|
// Refresh to restore correct state on error
|
||||||
|
|
@ -1008,7 +1098,7 @@ async function showMcpServerDetails(name: string) {
|
||||||
detailsModal?.classList.remove("hidden");
|
detailsModal?.classList.remove("hidden");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const details = await ipcRenderer.invoke("get-mcp-server-details", name);
|
const details = await ipcAdapter.invoke("get-mcp-server-details", name);
|
||||||
|
|
||||||
if (detailsContent) {
|
if (detailsContent) {
|
||||||
let html = "";
|
let html = "";
|
||||||
|
|
@ -1169,7 +1259,7 @@ addMcpBtn?.addEventListener("click", async () => {
|
||||||
addMcpBtn.classList.add("opacity-50", "cursor-not-allowed");
|
addMcpBtn.classList.add("opacity-50", "cursor-not-allowed");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await ipcRenderer.invoke("add-mcp-server", name, config);
|
await ipcAdapter.invoke("add-mcp-server", name, config);
|
||||||
mcpModal?.classList.add("hidden");
|
mcpModal?.classList.add("hidden");
|
||||||
// Force immediate refresh of MCP servers
|
// Force immediate refresh of MCP servers
|
||||||
await refreshMcpServers();
|
await refreshMcpServers();
|
||||||
|
|
@ -1206,7 +1296,7 @@ removeMcpDetailsBtn?.addEventListener("click", async () => {
|
||||||
renderMcpServers();
|
renderMcpServers();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await ipcRenderer.invoke("remove-mcp-server", serverName);
|
await ipcAdapter.invoke("remove-mcp-server", serverName);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert(`Failed to remove server: ${error}`);
|
alert(`Failed to remove server: ${error}`);
|
||||||
// Refresh to restore correct state on error
|
// Refresh to restore correct state on error
|
||||||
|
|
@ -1216,7 +1306,7 @@ removeMcpDetailsBtn?.addEventListener("click", async () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen for MCP polling started event
|
// 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) {
|
if (sessionId === activeSessionId) {
|
||||||
const addMcpServerBtn = document.getElementById("add-mcp-server");
|
const addMcpServerBtn = document.getElementById("add-mcp-server");
|
||||||
if (addMcpServerBtn) {
|
if (addMcpServerBtn) {
|
||||||
|
|
@ -1227,7 +1317,7 @@ ipcRenderer.on("mcp-polling-started", (_event, sessionId: string) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen for MCP server updates from main process
|
// 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
|
// Only update if this is for the active session
|
||||||
if (sessionId === activeSessionId) {
|
if (sessionId === activeSessionId) {
|
||||||
mcpServers = servers;
|
mcpServers = servers;
|
||||||
|
|
@ -1258,7 +1348,7 @@ const browseWorktreeDirBtn = document.getElementById("browse-worktree-dir");
|
||||||
|
|
||||||
// Load saved settings on startup
|
// Load saved settings on startup
|
||||||
async function loadSettings() {
|
async function loadSettings() {
|
||||||
const savedSettings = await ipcRenderer.invoke("get-terminal-settings");
|
const savedSettings = await ipcAdapter.invoke("get-terminal-settings");
|
||||||
if (savedSettings) {
|
if (savedSettings) {
|
||||||
terminalSettings = { ...DEFAULT_SETTINGS, ...savedSettings };
|
terminalSettings = { ...DEFAULT_SETTINGS, ...savedSettings };
|
||||||
}
|
}
|
||||||
|
|
@ -1320,9 +1410,21 @@ resetSettingsBtn?.addEventListener("click", () => {
|
||||||
populateSettingsForm();
|
populateSettingsForm();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// In web mode, allow manual text input for worktree directory
|
||||||
|
if (isWebMode && settingsWorktreeDir) {
|
||||||
|
settingsWorktreeDir.removeAttribute("readonly");
|
||||||
|
}
|
||||||
|
|
||||||
// Browse worktree directory
|
// Browse worktree directory
|
||||||
browseWorktreeDirBtn?.addEventListener("click", async () => {
|
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) {
|
if (dir) {
|
||||||
settingsWorktreeDir.value = dir;
|
settingsWorktreeDir.value = dir;
|
||||||
}
|
}
|
||||||
|
|
@ -1338,7 +1440,7 @@ saveSettingsBtn?.addEventListener("click", async () => {
|
||||||
terminalSettings.worktreeDir = settingsWorktreeDir.value || DEFAULT_SETTINGS.worktreeDir;
|
terminalSettings.worktreeDir = settingsWorktreeDir.value || DEFAULT_SETTINGS.worktreeDir;
|
||||||
|
|
||||||
// Save to electron-store
|
// Save to electron-store
|
||||||
await ipcRenderer.invoke("save-terminal-settings", terminalSettings);
|
await ipcAdapter.invoke("save-terminal-settings", terminalSettings);
|
||||||
|
|
||||||
// Apply to all existing terminals
|
// Apply to all existing terminals
|
||||||
applySettingsToAllTerminals();
|
applySettingsToAllTerminals();
|
||||||
|
|
@ -1349,3 +1451,39 @@ saveSettingsBtn?.addEventListener("click", async () => {
|
||||||
|
|
||||||
// Load settings on startup
|
// Load settings on startup
|
||||||
loadSettings();
|
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}`);
|
||||||
|
});
|
||||||
136
styles.css
136
styles.css
|
|
@ -53,7 +53,7 @@
|
||||||
|
|
||||||
/* Session List Item */
|
/* Session List Item */
|
||||||
.session-list-item {
|
.session-list-item {
|
||||||
@apply px-3 py-2 rounded cursor-pointer hover:bg-gray-700 text-gray-300 text-sm flex items-center justify-between;
|
@apply mb-3 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 {
|
.session-list-item.active {
|
||||||
|
|
@ -74,7 +74,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.session-delete-btn {
|
.session-delete-btn {
|
||||||
@apply text-gray-500 hover:text-red-500;
|
@apply text-gray-500 hover:text-red-500 ml-2;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.2s;
|
transition: opacity 0.2s;
|
||||||
}
|
}
|
||||||
|
|
@ -113,15 +113,25 @@
|
||||||
@apply bg-gray-800 border-b-2 border-blue-500;
|
@apply bg-gray-800 border-b-2 border-blue-500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab.unread .tab-name::after {
|
.unread-indicator {
|
||||||
content: '';
|
display: none;
|
||||||
display: inline-block;
|
width: 10px;
|
||||||
width: 6px;
|
height: 10px;
|
||||||
height: 6px;
|
|
||||||
background-color: #3b82f6;
|
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
margin-left: 6px;
|
margin-right: 6px;
|
||||||
vertical-align: middle;
|
}
|
||||||
|
|
||||||
|
.tab.unread .unread-indicator {
|
||||||
|
display: inline-block;
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-name {
|
.tab-name {
|
||||||
|
|
@ -220,4 +230,110 @@
|
||||||
.btn-primary.loading .loading-spinner {
|
.btn-primary.loading .loading-spinner {
|
||||||
@apply border-white border-t-transparent;
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
20
terminal-utils.ts
Normal file
20
terminal-utils.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue