feat: xterm.js + node-pty architecture for Claude CLI web terminal
Complete rewrite from tmux + JSONL parsing to a clean PTY-based approach. The proxy spawns Claude CLI in a pseudo-terminal via node-pty and relays terminal I/O as binary WebSocket frames to the simple-chat backend, which forwards them to the browser where xterm.js renders a full terminal. - node-pty PTY manager with 50KB replay buffer per session - Binary frame protocol with chatId multiplexing - WebSocket client with auto-reconnection and heartbeat - Directory listing and session listing for the web UI - README with setup instructions Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
8
.env.example
Normal file
8
.env.example
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
# Токен устройства (получить в simple-chat: Настройки → Устройство)
|
||||||
|
PROXY_TOKEN=
|
||||||
|
|
||||||
|
# WebSocket URL бэкенда simple-chat
|
||||||
|
# Локальная разработка:
|
||||||
|
SERVER_URL=ws://localhost:3000/ws/agent-proxy
|
||||||
|
# Продакшен:
|
||||||
|
# SERVER_URL=wss://ai-chat.vigdorov.ru/ws/agent-proxy
|
||||||
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.log
|
||||||
|
*.tsbuildinfo
|
||||||
|
.DS_Store
|
||||||
|
coverage/
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
.claude/
|
||||||
|
.serena
|
||||||
1
.npmrc
Normal file
1
.npmrc
Normal file
@ -0,0 +1 @@
|
|||||||
|
@vigdorov:registry=https://git.vigdorov.ru/api/packages/vigdorov/npm/
|
||||||
1
.prettierrc
Normal file
1
.prettierrc
Normal file
@ -0,0 +1 @@
|
|||||||
|
"@vigdorov/prettier-config"
|
||||||
261
ARCHITECTURE.md
Normal file
261
ARCHITECTURE.md
Normal file
@ -0,0 +1,261 @@
|
|||||||
|
# Claude CLI Proxy — Архитектура
|
||||||
|
|
||||||
|
## Обзор
|
||||||
|
|
||||||
|
claude-cli-proxy — это локальное приложение на Node.js, которое соединяет Claude Code CLI с веб-интерфейсом simple-chat. Оно подключается к бэкенду simple-chat через исходящее WebSocket-соединение, управляет tmux-сессиями для запуска экземпляров Claude CLI и отслеживает их вывод через JSONL-файлы транскриптов и захват терминала.
|
||||||
|
|
||||||
|
## Диаграмма компонентов
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Mac разработчика │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ claude-cli-proxy │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ┌─────────────┐ ┌──────────────┐ ┌──────────────┐ │ │
|
||||||
|
│ │ │ WsClient │ │ TmuxManager │ │SessionMonitor│ │ │
|
||||||
|
│ │ │(connection/)│ │ (tmux/) │ │ (monitor/) │ │ │
|
||||||
|
│ │ └──────┬──────┘ └──────┬───────┘ └──────┬───────┘ │ │
|
||||||
|
│ │ │ │ │ │ │
|
||||||
|
│ │ │ ┌──────┴───────┐ ┌──────┴───────┐ │ │
|
||||||
|
│ │ │ │ tmux-сессия │ │ JSONL-файлы │ │ │
|
||||||
|
│ │ │ │"claude-proxy"│ │ ~/.claude/ │ │ │
|
||||||
|
│ │ │ │ ├─ окно 1 │ │ projects/... │ │ │
|
||||||
|
│ │ │ │ ├─ окно 2 │ └──────────────┘ │ │
|
||||||
|
│ │ │ │ └─ ... │ │ │
|
||||||
|
│ │ │ └──────────────┘ │ │
|
||||||
|
│ └─────────┼────────────────────────────────────────────────┘ │
|
||||||
|
│ │ WebSocket │
|
||||||
|
└────────────┼────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌────────┴────────┐
|
||||||
|
│ simple-chat │
|
||||||
|
│ бэкенд │
|
||||||
|
│ (модуль │
|
||||||
|
│ agent-proxy) │
|
||||||
|
└─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Компоненты
|
||||||
|
|
||||||
|
### 1. Точка входа (`main.ts`)
|
||||||
|
|
||||||
|
Класс `ClaudeCliProxy` оркестрирует запуск:
|
||||||
|
1. Загрузка конфигурации из `.env`
|
||||||
|
2. Установка/обновление хука SessionStart для Claude CLI
|
||||||
|
3. Подключение к бэкенду через WebSocket
|
||||||
|
4. Инициализация tmux-сессии
|
||||||
|
5. Запуск мониторинга активных окон
|
||||||
|
|
||||||
|
### 2. WebSocket-клиент (`connection/ws-client.ts`)
|
||||||
|
|
||||||
|
- Подключается к `SERVER_URL` с `PROXY_TOKEN` для аутентификации
|
||||||
|
- Обрабатывает входящие команды от бэкенда
|
||||||
|
- Отправляет события (сообщения, обновления статуса, скриншоты) на бэкенд
|
||||||
|
- Переподключается с экспоненциальной задержкой при разрыве соединения
|
||||||
|
|
||||||
|
### 3. Протокол (`connection/protocol.ts`)
|
||||||
|
|
||||||
|
Общие определения типов сообщений для WebSocket-протокола.
|
||||||
|
|
||||||
|
#### Сообщения от бэкенда к прокси
|
||||||
|
|
||||||
|
| Сообщение | Описание |
|
||||||
|
|-----------|----------|
|
||||||
|
| `sync_state` | Отправить текущее состояние всех окон при подключении |
|
||||||
|
| `create_window` | Создать новое tmux-окно, запустить `claude` или `claude --resume <id>` |
|
||||||
|
| `send_message` | Отправить текстовый ввод в Claude CLI в указанном окне |
|
||||||
|
| `send_command` | Отправить произвольную команду в tmux-панель |
|
||||||
|
| `send_command_capture` | Отправить команду и захватить вывод |
|
||||||
|
| `send_key` | Отправить нажатие клавиши (например, Enter, Escape, Tab) |
|
||||||
|
| `kill_window` | Закрыть tmux-окно |
|
||||||
|
| `request_screenshot` | Захватить текущее содержимое терминала |
|
||||||
|
| `list_directories` | Получить список доступных рабочих директорий |
|
||||||
|
| `list_sessions` | Получить список возобновляемых сессий Claude |
|
||||||
|
|
||||||
|
#### Сообщения от прокси к бэкенду
|
||||||
|
|
||||||
|
| Сообщение | Описание |
|
||||||
|
|-----------|----------|
|
||||||
|
| `window_ready` | Новое окно создано, сообщается windowId |
|
||||||
|
| `message` | Новое сообщение ассистента/пользователя из JSONL-транскрипта |
|
||||||
|
| `status` | Изменение статуса Claude CLI (размышление, использование инструмента, простой) |
|
||||||
|
| `interactive` | Обнаружен интерактивный UI (запрос разрешения и т.д.) |
|
||||||
|
| `status_line` | Разобранные данные строки состояния (модель, контекст %, сессия %) |
|
||||||
|
| `screenshot` | Скриншот терминала (захваченное содержимое панели) |
|
||||||
|
| `window_closed` | Окно было закрыто или процесс завершился |
|
||||||
|
| `directories` | Ответ на list_directories |
|
||||||
|
| `sessions_list` | Ответ на list_sessions |
|
||||||
|
| `command_output` | Ответ на send_command_capture |
|
||||||
|
| `error` | Отчёт об ошибке |
|
||||||
|
|
||||||
|
### 4. Менеджер Tmux (`tmux/manager.ts`)
|
||||||
|
|
||||||
|
Управляет tmux-сессией `claude-proxy` (настраивается через `TMUX_SESSION`).
|
||||||
|
|
||||||
|
Операции:
|
||||||
|
- **Создание окна:** `tmux new-window` с командой `claude` или `claude --resume <sessionId>`
|
||||||
|
- **Удаление окна:** `tmux kill-window -t <windowId>`
|
||||||
|
- **Отправка клавиш:** `tmux send-keys -t <windowId>` для пользовательского ввода
|
||||||
|
- **Захват панели:** `tmux capture-pane -t <windowId> -p` для скриншотов и строки состояния
|
||||||
|
|
||||||
|
Каждый чат в simple-chat соответствует одному tmux-окну. Идентификатор окна хранится в поле `agentWindowId` чата.
|
||||||
|
|
||||||
|
### 5. Монитор сессий (`monitor/session-monitor.ts`)
|
||||||
|
|
||||||
|
Оркестратор, запускающий два цикла опроса для каждого активного окна:
|
||||||
|
|
||||||
|
#### Опрос JSONL (интервал 2 секунды)
|
||||||
|
|
||||||
|
1. Определение пути к JSONL-файлу по sessionId: `~/.claude/projects/{encoded_cwd}/{sessionId}.jsonl`
|
||||||
|
2. Чтение файла с последнего известного смещения в байтах (хранится в `monitor-state.ts`)
|
||||||
|
3. Парсинг новых JSONL-записей с помощью `jsonl-parser.ts`
|
||||||
|
4. Фильтрация системных/внутренних сообщений
|
||||||
|
5. Отправка релевантных сообщений на бэкенд через WebSocket
|
||||||
|
|
||||||
|
#### Опрос терминала (интервал 1 секунда)
|
||||||
|
|
||||||
|
1. Захват последних 3 строк tmux-панели (область строки состояния)
|
||||||
|
2. Парсинг с помощью `terminal-parser.ts` для извлечения:
|
||||||
|
- **Строка 1:** Название проекта, использование сессии %, использование за неделю %
|
||||||
|
- **Строка 2:** Название модели, использование контекста %
|
||||||
|
- **Строка 3:** Индикатор режима разрешений
|
||||||
|
3. Обнаружение интерактивных состояний UI (запросы разрешений, запросы ввода)
|
||||||
|
4. Отправка событий `status_line`, `status` и `interactive` на бэкенд
|
||||||
|
|
||||||
|
### 6. JSONL-парсер (`monitor/jsonl-parser.ts`)
|
||||||
|
|
||||||
|
Разбирает файлы транскриптов Claude CLI в формате JSONL. Каждая строка — это JSON-объект, представляющий событие разговора. Парсер:
|
||||||
|
- Читает новые байты с последнего смещения
|
||||||
|
- Разбирает каждую строку как JSON
|
||||||
|
- Фильтрует системные сообщения (инициализация, конфигурация и т.д.)
|
||||||
|
- Извлекает сообщения пользователя, сообщения ассистента, вызовы инструментов и результаты инструментов
|
||||||
|
|
||||||
|
### 7. Парсер терминала (`monitor/terminal-parser.ts`)
|
||||||
|
|
||||||
|
Разбирает строку состояния Claude CLI, отображаемую в нижней части терминала:
|
||||||
|
|
||||||
|
```
|
||||||
|
Строка 1: agent_dev │ session: 24% │ week: 3%
|
||||||
|
Строка 2: Opus 4.6 (1M context) │ Ctx: 2%
|
||||||
|
Строка 3: ⏵⏵ bypass permissions on (shift+tab to cycle)
|
||||||
|
```
|
||||||
|
|
||||||
|
Извлекает структурированные данные: название проекта, модель, процент контекста, использование сессии/недели, режим разрешений.
|
||||||
|
|
||||||
|
Также обнаруживает интерактивные состояния UI, когда Claude ожидает ввода пользователя (запросы разрешений, вопросы да/нет).
|
||||||
|
|
||||||
|
### 8. Состояние монитора (`monitor/monitor-state.ts`)
|
||||||
|
|
||||||
|
Отслеживает состояние мониторинга для каждого окна:
|
||||||
|
- **Смещение в байтах:** Последняя позиция чтения в JSONL-файле (для инкрементального чтения)
|
||||||
|
- **Кеш mtime:** Время модификации файла (пропуск повторного чтения неизменённых файлов)
|
||||||
|
|
||||||
|
### 9. Система хуков (`hook/`)
|
||||||
|
|
||||||
|
#### hook.ts
|
||||||
|
|
||||||
|
Управляет хуком SessionStart для Claude CLI:
|
||||||
|
- Устанавливает/обновляет запись хука в `~/.claude/settings.json`
|
||||||
|
- Предоставляет CRUD-операции для `~/.claude-proxy/session_map.json`
|
||||||
|
|
||||||
|
#### session-start-hook.ts
|
||||||
|
|
||||||
|
Скрипт, который запускается при старте новой сессии Claude CLI. Он:
|
||||||
|
1. Получает sessionId от Claude CLI
|
||||||
|
2. Определяет текущий идентификатор tmux-окна
|
||||||
|
3. Записывает маппинг `{windowId: sessionId}` в `~/.claude-proxy/session_map.json`
|
||||||
|
|
||||||
|
Этот маппинг необходим, потому что путь к JSONL-файлу зависит от sessionId, который становится известен только после запуска Claude CLI.
|
||||||
|
|
||||||
|
### 10. Карта сессий
|
||||||
|
|
||||||
|
Хранится в `~/.claude-proxy/session_map.json`.
|
||||||
|
|
||||||
|
Сопоставляет идентификаторы tmux-окон с идентификаторами сессий Claude CLI:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"3": "abc123-def456",
|
||||||
|
"5": "789ghi-012jkl"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Используется монитором для нахождения правильного JSONL-файла транскрипта для каждого окна.
|
||||||
|
|
||||||
|
### 11. Загрузчик файлов (`files/downloader.ts`)
|
||||||
|
|
||||||
|
Скачивает файлы по URL из MinIO, на которые есть ссылки в сообщениях. Используется, когда вывод Claude CLI содержит ссылки на файлы, которые должны быть доступны через веб-интерфейс.
|
||||||
|
|
||||||
|
## Потоки данных
|
||||||
|
|
||||||
|
### Поток создания нового чата
|
||||||
|
|
||||||
|
1. Пользователь создаёт чат в веб-интерфейсе simple-chat
|
||||||
|
2. Бэкенд отправляет `create_window` прокси через WebSocket
|
||||||
|
3. Прокси создаёт tmux-окно, запускает `claude` (или `claude --resume <id>`)
|
||||||
|
4. Claude CLI запускается, срабатывает хук SessionStart
|
||||||
|
5. Хук записывает маппинг sessionId -> windowId в session_map.json
|
||||||
|
6. Прокси читает session_map, начинает мониторинг JSONL-файла и терминала
|
||||||
|
7. Прокси отправляет `window_ready` на бэкенд с windowId
|
||||||
|
|
||||||
|
### Поток сообщений
|
||||||
|
|
||||||
|
1. Пользователь вводит сообщение в simple-chat
|
||||||
|
2. Бэкенд отправляет `send_message` прокси
|
||||||
|
3. Прокси отправляет нажатия клавиш в tmux-окно через `tmux send-keys`
|
||||||
|
4. Claude CLI обрабатывает сообщение, записывает в JSONL-транскрипт
|
||||||
|
5. Опросчик JSONL обнаруживает новые байты, разбирает сообщения
|
||||||
|
6. Прокси отправляет события `message` на бэкенд
|
||||||
|
7. Бэкенд передаёт их на фронтенд через SSE
|
||||||
|
|
||||||
|
### Поток строки состояния
|
||||||
|
|
||||||
|
1. Опросчик терминала захватывает последние 3 строки tmux-панели каждую секунду
|
||||||
|
2. Парсер извлекает модель, контекст %, статистику использования
|
||||||
|
3. Прокси отправляет `status_line` на бэкенд
|
||||||
|
4. Фронтенд отображает строку состояния в реальном времени в UI чата
|
||||||
|
|
||||||
|
## Кодирование пути JSONL
|
||||||
|
|
||||||
|
Claude CLI хранит транскрипты по пути:
|
||||||
|
```
|
||||||
|
~/.claude/projects/{encoded_cwd}/{sessionId}.jsonl
|
||||||
|
```
|
||||||
|
|
||||||
|
Кодирование пути: символы `/` и `_` в пути рабочей директории заменяются на `-`.
|
||||||
|
|
||||||
|
Пример:
|
||||||
|
- Рабочая директория: `/Users/vigdorov/agent_dev`
|
||||||
|
- Закодированный путь: `-Users-vigdorov-agent-dev`
|
||||||
|
- Полный путь: `~/.claude/projects/-Users-vigdorov-agent-dev/{sessionId}.jsonl`
|
||||||
|
|
||||||
|
## Логика переподключения
|
||||||
|
|
||||||
|
WebSocket-клиент реализует экспоненциальную задержку:
|
||||||
|
1. При разрыве соединения ожидание `baseDelay` (начиная с ~1 секунды)
|
||||||
|
2. Каждая неудачная попытка переподключения удваивает задержку
|
||||||
|
3. Ограничение максимальной задержкой
|
||||||
|
4. При успешном переподключении отправляется `sync_state` с текущим состоянием окон
|
||||||
|
5. Сброс таймера задержки при успешном подключении
|
||||||
|
|
||||||
|
## Интеграция с simple-chat
|
||||||
|
|
||||||
|
### Бэкенд
|
||||||
|
|
||||||
|
- Модуль: `agent-proxy`
|
||||||
|
- WebSocket gateway обрабатывает подключения прокси
|
||||||
|
- REST API для управления agent-устройствами
|
||||||
|
- SSE relay передаёт обновления в реальном времени на фронтенд
|
||||||
|
|
||||||
|
### Фронтенд
|
||||||
|
|
||||||
|
- Маршрут: `/agent`
|
||||||
|
- `AgentPage` — список устройств/чатов
|
||||||
|
- `AgentChatPage` — вид чата с сообщениями в реальном времени и строкой состояния
|
||||||
|
- Slash-команды: `/model` (выбор модели), `/resume` (выбор сессии), `/clear`, `/compact`, `/plan`
|
||||||
|
|
||||||
|
### База данных
|
||||||
|
|
||||||
|
- Таблица `agent_devices` — зарегистрированные прокси-устройства
|
||||||
|
- Поля таблицы `chats`: `agentWindowId`, `agentWorkDir`, `agentSessionId`
|
||||||
73
CLAUDE.md
Normal file
73
CLAUDE.md
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
# Claude CLI Proxy — Веб-клиент для Claude Code CLI
|
||||||
|
|
||||||
|
## Назначение
|
||||||
|
|
||||||
|
Локальное приложение, которое подключает Claude Code CLI к веб-интерфейсу simple-chat. Управляет PTY-процессами (node-pty), транслирует ввод/вывод терминала через WebSocket. Фронтенд отображает терминал через xterm.js.
|
||||||
|
|
||||||
|
**Это локальное приложение.** Запускается на машине разработчика, не деплоится в k3s.
|
||||||
|
|
||||||
|
## Статус
|
||||||
|
|
||||||
|
Активная разработка. Работает в связке с simple-chat (модуль agent-proxy).
|
||||||
|
|
||||||
|
## Архитектура
|
||||||
|
|
||||||
|
```
|
||||||
|
simple-chat (frontend) simple-chat (backend) claude-cli-proxy (local)
|
||||||
|
xterm.js terminal ←─WS─→ agent-proxy gateway ←──WS──→ PtyManager
|
||||||
|
│ │
|
||||||
|
│ node-pty processes
|
||||||
|
│ ├── claude (chat 1)
|
||||||
|
│ ├── claude --resume <id> (chat 2)
|
||||||
|
│ └── ...
|
||||||
|
│
|
||||||
|
REST API + DB (agent_devices, chats)
|
||||||
|
```
|
||||||
|
|
||||||
|
Подробности: [ARCHITECTURE.md](ARCHITECTURE.md) | Требования: [REQUIREMENTS.md](REQUIREMENTS.md)
|
||||||
|
|
||||||
|
## Технологический стек
|
||||||
|
|
||||||
|
| Компонент | Технология |
|
||||||
|
|-----------|-----------|
|
||||||
|
| Runtime | Node.js + TypeScript |
|
||||||
|
| WebSocket | ws |
|
||||||
|
| PTY | node-pty |
|
||||||
|
| Frontend terminal | xterm.js (в simple-chat) |
|
||||||
|
| Запуск | `npm start` → `tsx src/main.ts` |
|
||||||
|
| Общие конфиги | @vigdorov/eslint-config (node), @vigdorov/prettier-config, @vigdorov/typescript-config (node) |
|
||||||
|
|
||||||
|
## Структура
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── main.ts # Точка входа, класс ClaudeCliProxy
|
||||||
|
├── config.ts # Загрузка .env (PROXY_TOKEN, SERVER_URL)
|
||||||
|
├── connection/
|
||||||
|
│ ├── ws-client.ts # WebSocket-клиент с переподключением
|
||||||
|
│ └── protocol.ts # Типы WS-сообщений, бинарный протокол (общие с бэкендом)
|
||||||
|
└── pty/
|
||||||
|
└── manager.ts # Создание/удаление/resize PTY-процессов через node-pty
|
||||||
|
```
|
||||||
|
|
||||||
|
## Конфигурация (.env)
|
||||||
|
|
||||||
|
| Переменная | Описание |
|
||||||
|
|-----------|----------|
|
||||||
|
| `PROXY_TOKEN` | Токен авторизации (из настроек simple-chat) |
|
||||||
|
| `SERVER_URL` | WebSocket URL бэкенда (`ws://localhost:3000/ws/agent-proxy` или `wss://ai-chat.vigdorov.ru/ws/agent-proxy`) |
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dev-порт: нет (только исходящие соединения, подключается к серверу)
|
||||||
|
|
||||||
|
## Связь с simple-chat
|
||||||
|
|
||||||
|
- Бэкенд: модуль `agent-proxy` (WebSocket gateway, REST API, бинарный протокол PTY ввода/вывода)
|
||||||
|
- Фронтенд: маршрут `/agent` с терминалом xterm.js, управление чатами/сессиями
|
||||||
|
- БД: таблица `agent_devices` + agent-поля в таблице `chats`
|
||||||
118
README.md
Normal file
118
README.md
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
# Claude CLI Proxy
|
||||||
|
|
||||||
|
Локальное приложение, которое позволяет управлять [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) через веб-браузер. Терминал Claude Code отображается прямо в браузере с помощью xterm.js — полноценный терминал с цветами, автокомплитом и интерактивным UI.
|
||||||
|
|
||||||
|
Работает в связке с [simple-chat](https://git.vigdorov.ru/vigdorov/simple-chat) — веб-приложением для чатов с AI.
|
||||||
|
|
||||||
|
## Как это работает
|
||||||
|
|
||||||
|
```
|
||||||
|
Браузер (xterm.js) ←──WebSocket──→ simple-chat (backend) ←──WebSocket──→ claude-cli-proxy (local)
|
||||||
|
│
|
||||||
|
node-pty
|
||||||
|
│
|
||||||
|
Claude Code CLI
|
||||||
|
```
|
||||||
|
|
||||||
|
1. **claude-cli-proxy** запускается на вашем компьютере рядом с Claude Code CLI
|
||||||
|
2. Прокси подключается к бэкенду simple-chat через WebSocket
|
||||||
|
3. При создании чата в веб-интерфейсе, прокси запускает Claude CLI в псевдо-терминале (PTY)
|
||||||
|
4. Ввод/вывод терминала транслируется через WebSocket в браузер
|
||||||
|
5. Браузер отображает полноценный терминал через xterm.js
|
||||||
|
|
||||||
|
## Требования
|
||||||
|
|
||||||
|
- **Node.js** 18+ (рекомендуется 20+)
|
||||||
|
- **Claude Code CLI** установлен и доступен в PATH (`claude` команда)
|
||||||
|
- Работающий инстанс **simple-chat** (бэкенд + фронтенд)
|
||||||
|
|
||||||
|
## Установка
|
||||||
|
|
||||||
|
### 1. Настройте simple-chat
|
||||||
|
|
||||||
|
Прокси работает только вместе с simple-chat. Убедитесь что simple-chat бэкенд запущен.
|
||||||
|
|
||||||
|
### 2. Создайте устройство в simple-chat
|
||||||
|
|
||||||
|
Откройте simple-chat в браузере, перейдите в **Настройки → Устройство** и создайте новое устройство. Скопируйте **токен** — он понадобится для подключения прокси.
|
||||||
|
|
||||||
|
### 3. Установите зависимости
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.vigdorov.ru/vigdorov/claude-cli-proxy.git
|
||||||
|
cd claude-cli-proxy
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Создайте файл `.env`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Отредактируйте `.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Токен устройства из настроек simple-chat
|
||||||
|
PROXY_TOKEN=ваш_токен_из_шага_2
|
||||||
|
|
||||||
|
# URL бэкенда simple-chat
|
||||||
|
# Для локальной разработки:
|
||||||
|
SERVER_URL=ws://localhost:3000/ws/agent-proxy
|
||||||
|
# Для продакшена:
|
||||||
|
# SERVER_URL=wss://ai-chat.vigdorov.ru/ws/agent-proxy
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Запустите
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
В консоли должно появиться:
|
||||||
|
```
|
||||||
|
[proxy] Starting claude-cli-proxy (xterm mode)...
|
||||||
|
[proxy] Running. Press Ctrl+C to stop.
|
||||||
|
[WS] Connected to server
|
||||||
|
[proxy] Connected to server, waiting for sync...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Использование
|
||||||
|
|
||||||
|
1. Откройте simple-chat в браузере
|
||||||
|
2. В сайдбаре нажмите **Claude Agent**
|
||||||
|
3. Нажмите **+** чтобы создать новый чат
|
||||||
|
4. Выберите рабочую директорию (проект)
|
||||||
|
5. Терминал Claude Code откроется прямо в браузере
|
||||||
|
|
||||||
|
### Мобильные устройства
|
||||||
|
|
||||||
|
На мобиле и планшете внизу экрана отображаются кнопки управления:
|
||||||
|
|
||||||
|
- **Горячие клавиши:** Esc, Tab, ↑, ↓, y, n, Enter, Ctrl+C, Ctrl+O, ⇧Tab
|
||||||
|
- **Быстрые команды:** /compact, /clear, /model, /help
|
||||||
|
|
||||||
|
На десктопе кнопки скрыты — для показа нажмите иконку клавиатуры в правом нижнем углу.
|
||||||
|
|
||||||
|
## Конфигурация
|
||||||
|
|
||||||
|
| Переменная | Описание | По умолчанию |
|
||||||
|
|-----------|----------|--------------|
|
||||||
|
| `PROXY_TOKEN` | Токен устройства из настроек simple-chat | — (обязательно) |
|
||||||
|
| `SERVER_URL` | WebSocket URL бэкенда | `wss://ai-chat.vigdorov.ru/ws/agent-proxy` |
|
||||||
|
|
||||||
|
Файл `.env` можно также разместить в `~/.claude-proxy/.env` — он будет загружен первым.
|
||||||
|
|
||||||
|
## Безопасность
|
||||||
|
|
||||||
|
- Прокси подключается к бэкенду **исходящим** соединением (не открывает порты)
|
||||||
|
- Аутентификация по токену устройства
|
||||||
|
- Терминальные данные передаются через бинарные WebSocket-фреймы
|
||||||
|
- При использовании через интернет рекомендуется WSS (TLS)
|
||||||
|
|
||||||
|
## Технологии
|
||||||
|
|
||||||
|
- **Node.js + TypeScript** — runtime
|
||||||
|
- **node-pty** — псевдо-терминал для запуска Claude CLI
|
||||||
|
- **ws** — WebSocket клиент
|
||||||
|
- **xterm.js** — терминал в браузере (на стороне simple-chat)
|
||||||
117
REQUIREMENTS.md
Normal file
117
REQUIREMENTS.md
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
# Claude CLI Proxy — Требования
|
||||||
|
|
||||||
|
## Функциональные требования
|
||||||
|
|
||||||
|
### FR-1: WebSocket-соединение с бэкендом
|
||||||
|
|
||||||
|
- Подключение к бэкенду simple-chat через WebSocket по настраиваемому URL
|
||||||
|
- Аутентификация с помощью `PROXY_TOKEN`
|
||||||
|
- Отправка идентификации устройства при подключении
|
||||||
|
- Поддержание постоянного соединения с автоматическим переподключением
|
||||||
|
|
||||||
|
### FR-2: Управление tmux-сессиями
|
||||||
|
|
||||||
|
- Управление одной tmux-сессией (настраиваемое имя, по умолчанию `claude-proxy`)
|
||||||
|
- Создание новых tmux-окон по запросу (одно на чат)
|
||||||
|
- Запуск `claude` CLI в новых окнах или `claude --resume <sessionId>` для существующих сессий
|
||||||
|
- Закрытие окон при завершении чатов
|
||||||
|
- Отправка нажатий клавиш в окна (сообщения пользователя, клавиши)
|
||||||
|
- Захват содержимого панели для скриншотов
|
||||||
|
|
||||||
|
### FR-3: Мониторинг JSONL-транскриптов
|
||||||
|
|
||||||
|
- Отслеживание JSONL-файлов транскриптов Claude CLI на предмет нового содержимого
|
||||||
|
- Инкрементальное чтение с отслеживанием смещения в байтах (без полного перечитывания)
|
||||||
|
- Парсинг JSONL-записей: сообщения пользователя, сообщения ассистента, вызовы инструментов, результаты инструментов
|
||||||
|
- Фильтрация системных/внутренних сообщений (события инициализации, конфигурации)
|
||||||
|
- Определение правильного пути к файлу через карту сессий и закодированную рабочую директорию
|
||||||
|
- Интервал опроса: 2 секунды
|
||||||
|
|
||||||
|
### FR-4: Мониторинг строки состояния терминала
|
||||||
|
|
||||||
|
- Захват последних 3 строк каждой активной tmux-панели
|
||||||
|
- Парсинг строки состояния для извлечения:
|
||||||
|
- Названия проекта
|
||||||
|
- Названия модели и размера контекстного окна
|
||||||
|
- Процента использования контекста
|
||||||
|
- Процентов использования сессии и недели
|
||||||
|
- Режима разрешений
|
||||||
|
- Обнаружение интерактивных состояний UI (запросы разрешений, запросы ввода)
|
||||||
|
- Интервал опроса: 1 секунда
|
||||||
|
|
||||||
|
### FR-5: Маппинг сессий через хук
|
||||||
|
|
||||||
|
- Установка хука SessionStart в настройках Claude CLI (`~/.claude/settings.json`)
|
||||||
|
- Хук захватывает sessionId и текущий tmux windowId при запуске Claude CLI
|
||||||
|
- Сохранение маппинга в `~/.claude-proxy/session_map.json`
|
||||||
|
- Чтение карты сессий для нахождения JSONL-файлов активных окон
|
||||||
|
|
||||||
|
### FR-6: Выполнение команд
|
||||||
|
|
||||||
|
- Выполнение произвольных команд в tmux-панелях по запросу бэкенда
|
||||||
|
- Захват вывода команды и возврат на бэкенд (`send_command_capture`)
|
||||||
|
|
||||||
|
### FR-7: Список директорий и сессий
|
||||||
|
|
||||||
|
- Получение списка доступных рабочих директорий по запросу
|
||||||
|
- Получение списка возобновляемых сессий Claude CLI по запросу
|
||||||
|
|
||||||
|
### FR-8: Скачивание файлов
|
||||||
|
|
||||||
|
- Скачивание файлов по URL из MinIO, на которые есть ссылки в сообщениях
|
||||||
|
- Обеспечение доступа к файлам через веб-интерфейс
|
||||||
|
|
||||||
|
### FR-9: Синхронизация состояния
|
||||||
|
|
||||||
|
- При переподключении отправка полного состояния всех активных окон на бэкенд (`sync_state`)
|
||||||
|
- Отчёт о создании окон (`window_ready`) и их закрытии (`window_closed`)
|
||||||
|
|
||||||
|
## Нефункциональные требования
|
||||||
|
|
||||||
|
### NFR-1: Надёжность
|
||||||
|
|
||||||
|
- Переподключение к бэкенду с экспоненциальной задержкой при разрыве соединения
|
||||||
|
- Возобновление мониторинга после переподключения без потери состояния
|
||||||
|
- Корректная обработка падений tmux-процессов
|
||||||
|
- Обработка отсутствующих или повреждённых JSONL-файлов без аварийного завершения
|
||||||
|
|
||||||
|
### NFR-2: Производительность
|
||||||
|
|
||||||
|
- Опрос терминала с интервалом 1 секунда не должен вызывать заметную нагрузку на CPU
|
||||||
|
- Опрос JSONL с интервалом 2 секунды использует отслеживание смещения в байтах, чтобы не перечитывать файлы целиком
|
||||||
|
- Кеш mtime полностью пропускает неизменённые файлы
|
||||||
|
- Эффективные tmux-операции через прямой `execFile` (без накладных расходов на shell)
|
||||||
|
|
||||||
|
### NFR-3: Переносимость
|
||||||
|
|
||||||
|
- Работает на macOS (основная машина разработчика)
|
||||||
|
- Требования: Node.js, tmux, Claude CLI (`claude` в PATH)
|
||||||
|
- Без Docker, без пайплайна деплоя
|
||||||
|
|
||||||
|
### NFR-4: Безопасность
|
||||||
|
|
||||||
|
- Аутентификация по токену для WebSocket-соединения
|
||||||
|
- Отсутствие слушающих портов (только исходящие соединения)
|
||||||
|
- Карта сессий хранится локально без удалённого доступа
|
||||||
|
|
||||||
|
### NFR-5: Поддерживаемость
|
||||||
|
|
||||||
|
- TypeScript со строгой типизацией
|
||||||
|
- Общие конфиги из @vigdorov/dev-configs (ESLint, Prettier, TSConfig)
|
||||||
|
- Модульная структура: connection, tmux, monitor, hook, files
|
||||||
|
|
||||||
|
## Обработка граничных случаев
|
||||||
|
|
||||||
|
| Сценарий | Поведение |
|
||||||
|
|----------|-----------|
|
||||||
|
| Бэкенд недоступен при запуске | Повтор с экспоненциальной задержкой |
|
||||||
|
| Бэкенд отключается во время сессии | Переподключение и синхронизация состояния |
|
||||||
|
| JSONL-файл ещё не создан | Пропуск мониторинга, повтор при следующем опросе |
|
||||||
|
| JSONL-файл удалён или перемещён | Сброс смещения в байтах, повторное обнаружение |
|
||||||
|
| tmux-сессия не запущена | Автоматическое создание сессии |
|
||||||
|
| Процесс tmux-окна завершился | Обнаружение через захват, отчёт `window_closed` |
|
||||||
|
| Карта сессий отсутствует | Создание пустого файла карты |
|
||||||
|
| Хук не установлен | Автоустановка при запуске прокси |
|
||||||
|
| Конфликт нескольких прокси | Один прокси на tmux-сессию по дизайну |
|
||||||
|
| Большие JSONL-файлы | Инкрементальное чтение через смещение в байтах, без полного перечитывания |
|
||||||
|
| Claude CLI отсутствует в PATH | Ошибка при создании окна, отчёт на бэкенд |
|
||||||
2
eslint.config.mjs
Normal file
2
eslint.config.mjs
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
import {node} from '@vigdorov/eslint-config';
|
||||||
|
export default node();
|
||||||
4866
package-lock.json
generated
Normal file
4866
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
package.json
Normal file
32
package.json
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "claude-cli-proxy",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "tsx src/main.ts",
|
||||||
|
"dev": "tsx watch src/main.ts",
|
||||||
|
"lint": "eslint src/",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@homebridge/node-pty-prebuilt-multiarch": "^0.13.1",
|
||||||
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
"@xterm/addon-web-links": "^0.12.0",
|
||||||
|
"@xterm/xterm": "^6.0.0",
|
||||||
|
"chokidar": "^4.0.0",
|
||||||
|
"dotenv": "^16.4.7",
|
||||||
|
"ws": "^8.18.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"@types/ws": "^8.5.0",
|
||||||
|
"@vigdorov/eslint-config": "^1.0.1",
|
||||||
|
"@vigdorov/prettier-config": "^1.0.0",
|
||||||
|
"@vigdorov/typescript-config": "^1.1.0",
|
||||||
|
"eslint": "^9.39.2",
|
||||||
|
"tsx": "^4.19.0",
|
||||||
|
"typescript": "^5.7.0",
|
||||||
|
"typescript-eslint": "^8.54.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/config.ts
Normal file
27
src/config.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { config } from 'dotenv';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
import { existsSync } from 'fs';
|
||||||
|
import { homedir } from 'os';
|
||||||
|
|
||||||
|
export interface ProxyConfig {
|
||||||
|
proxyToken: string;
|
||||||
|
serverUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadConfig(): ProxyConfig {
|
||||||
|
// Try ~/.claude-proxy/.env first, then local .env
|
||||||
|
const homeEnv = resolve(homedir(), '.claude-proxy', '.env');
|
||||||
|
if (existsSync(homeEnv)) {
|
||||||
|
config({ path: homeEnv });
|
||||||
|
}
|
||||||
|
config(); // local .env (won't override existing vars)
|
||||||
|
|
||||||
|
const proxyToken = process.env.PROXY_TOKEN;
|
||||||
|
if (!proxyToken) {
|
||||||
|
throw new Error('PROXY_TOKEN is required. Set it in .env or ~/.claude-proxy/.env');
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverUrl = process.env.SERVER_URL || 'wss://ai-chat.vigdorov.ru/ws/agent-proxy';
|
||||||
|
|
||||||
|
return { proxyToken, serverUrl };
|
||||||
|
}
|
||||||
108
src/connection/protocol.ts
Normal file
108
src/connection/protocol.ts
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
// === WebSocket Protocol Types (xterm.js + node-pty) ===
|
||||||
|
|
||||||
|
// Binary frame header for terminal data multiplexing:
|
||||||
|
// [1 byte direction][36 bytes chatId ASCII][...payload]
|
||||||
|
// Direction: 0x01 = PTY output (proxy→backend→frontend)
|
||||||
|
// 0x02 = user input (frontend→backend→proxy)
|
||||||
|
export const DIR_PTY_OUTPUT = 0x01;
|
||||||
|
export const DIR_USER_INPUT = 0x02;
|
||||||
|
export const CHAT_ID_LENGTH = 36; // UUID length
|
||||||
|
|
||||||
|
export function encodeBinaryFrame(direction: number, chatId: string, data: Buffer): Buffer {
|
||||||
|
const header = Buffer.alloc(1 + CHAT_ID_LENGTH);
|
||||||
|
header[0] = direction;
|
||||||
|
header.write(chatId, 1, CHAT_ID_LENGTH, 'ascii');
|
||||||
|
return Buffer.concat([header, data]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeBinaryFrame(frame: Buffer): { direction: number; chatId: string; data: Buffer } {
|
||||||
|
const direction = frame[0];
|
||||||
|
const chatId = frame.subarray(1, 1 + CHAT_ID_LENGTH).toString('ascii');
|
||||||
|
const data = frame.subarray(1 + CHAT_ID_LENGTH);
|
||||||
|
return { direction, chatId, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON control messages
|
||||||
|
|
||||||
|
export type BackendMessageType =
|
||||||
|
| 'sync_state'
|
||||||
|
| 'create_pty'
|
||||||
|
| 'kill_pty'
|
||||||
|
| 'resize'
|
||||||
|
| 'list_directories'
|
||||||
|
| 'list_sessions';
|
||||||
|
|
||||||
|
export type ProxyMessageType =
|
||||||
|
| 'pty_ready'
|
||||||
|
| 'pty_closed'
|
||||||
|
| 'directories'
|
||||||
|
| 'sessions_list'
|
||||||
|
| 'error';
|
||||||
|
|
||||||
|
export type WsMessageType = BackendMessageType | ProxyMessageType;
|
||||||
|
|
||||||
|
export interface WsMessage {
|
||||||
|
type: WsMessageType;
|
||||||
|
requestId?: string;
|
||||||
|
payload: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Payload types ===
|
||||||
|
|
||||||
|
export interface SyncStatePayload {
|
||||||
|
chats: AgentChatInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentChatInfo {
|
||||||
|
id: string;
|
||||||
|
workDir: string | null;
|
||||||
|
sessionId: string | null;
|
||||||
|
cols?: number;
|
||||||
|
rows?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreatePtyPayload {
|
||||||
|
chatId: string;
|
||||||
|
dir: string;
|
||||||
|
resumeSessionId?: string;
|
||||||
|
cols: number;
|
||||||
|
rows: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KillPtyPayload {
|
||||||
|
chatId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResizePayload {
|
||||||
|
chatId: string;
|
||||||
|
cols: number;
|
||||||
|
rows: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PtyReadyPayload {
|
||||||
|
chatId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PtyClosedPayload {
|
||||||
|
chatId: string;
|
||||||
|
exitCode: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListDirectoriesPayload {
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DirectoryEntry {
|
||||||
|
name: string;
|
||||||
|
isDirectory: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DirectoriesPayload {
|
||||||
|
requestId: string;
|
||||||
|
entries: DirectoryEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ErrorPayload {
|
||||||
|
chatId?: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
124
src/connection/ws-client.ts
Normal file
124
src/connection/ws-client.ts
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
import WebSocket from 'ws';
|
||||||
|
import type { WsMessage } from './protocol.js';
|
||||||
|
|
||||||
|
export type MessageHandler = (msg: WsMessage) => void;
|
||||||
|
export type BinaryHandler = (data: Buffer) => void;
|
||||||
|
|
||||||
|
interface WsClientOptions {
|
||||||
|
url: string;
|
||||||
|
token: string;
|
||||||
|
onMessage: MessageHandler;
|
||||||
|
onBinary: BinaryHandler;
|
||||||
|
onConnected: () => void;
|
||||||
|
onDisconnected: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIN_RECONNECT_DELAY = 1000;
|
||||||
|
const MAX_RECONNECT_DELAY = 30000;
|
||||||
|
const HEARTBEAT_INTERVAL = 30000;
|
||||||
|
|
||||||
|
export class WsClient {
|
||||||
|
private ws: WebSocket | null = null;
|
||||||
|
private reconnectDelay = MIN_RECONNECT_DELAY;
|
||||||
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private closed = false;
|
||||||
|
|
||||||
|
constructor(private options: WsClientOptions) {}
|
||||||
|
|
||||||
|
connect(): void {
|
||||||
|
this.closed = false;
|
||||||
|
this.doConnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
private doConnect(): void {
|
||||||
|
if (this.closed) return;
|
||||||
|
|
||||||
|
const url = `${this.options.url}?token=${encodeURIComponent(this.options.token)}`;
|
||||||
|
this.ws = new WebSocket(url);
|
||||||
|
|
||||||
|
this.ws.on('open', () => {
|
||||||
|
console.log('[WS] Connected to server');
|
||||||
|
this.reconnectDelay = MIN_RECONNECT_DELAY;
|
||||||
|
this.startHeartbeat();
|
||||||
|
this.options.onConnected();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.ws.on('message', (data, isBinary) => {
|
||||||
|
if (isBinary) {
|
||||||
|
this.options.onBinary(data as Buffer);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(data.toString()) as WsMessage;
|
||||||
|
this.options.onMessage(msg);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[WS] Failed to parse message:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.ws.on('close', (code, reason) => {
|
||||||
|
console.log(`[WS] Disconnected: ${code} ${reason.toString()}`);
|
||||||
|
this.stopHeartbeat();
|
||||||
|
this.options.onDisconnected();
|
||||||
|
this.scheduleReconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.ws.on('error', (err) => {
|
||||||
|
console.error('[WS] Error:', err.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
send(msg: WsMessage): void {
|
||||||
|
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||||
|
this.ws.send(JSON.stringify(msg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sendBinary(data: Buffer): void {
|
||||||
|
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||||
|
this.ws.send(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect(): void {
|
||||||
|
this.closed = true;
|
||||||
|
if (this.reconnectTimer) {
|
||||||
|
clearTimeout(this.reconnectTimer);
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
}
|
||||||
|
this.stopHeartbeat();
|
||||||
|
this.ws?.close();
|
||||||
|
this.ws = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isConnected(): boolean {
|
||||||
|
return this.ws?.readyState === WebSocket.OPEN;
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleReconnect(): void {
|
||||||
|
if (this.closed) return;
|
||||||
|
|
||||||
|
console.log(`[WS] Reconnecting in ${this.reconnectDelay}ms...`);
|
||||||
|
this.reconnectTimer = setTimeout(() => {
|
||||||
|
this.doConnect();
|
||||||
|
}, this.reconnectDelay);
|
||||||
|
|
||||||
|
this.reconnectDelay = Math.min(this.reconnectDelay * 2, MAX_RECONNECT_DELAY);
|
||||||
|
}
|
||||||
|
|
||||||
|
private startHeartbeat(): void {
|
||||||
|
this.heartbeatTimer = setInterval(() => {
|
||||||
|
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||||
|
this.ws.ping();
|
||||||
|
}
|
||||||
|
}, HEARTBEAT_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopHeartbeat(): void {
|
||||||
|
if (this.heartbeatTimer) {
|
||||||
|
clearInterval(this.heartbeatTimer);
|
||||||
|
this.heartbeatTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
263
src/main.ts
Normal file
263
src/main.ts
Normal file
@ -0,0 +1,263 @@
|
|||||||
|
import { loadConfig } from './config.js';
|
||||||
|
import { WsClient } from './connection/ws-client.js';
|
||||||
|
import { PtyManager } from './pty/manager.js';
|
||||||
|
import type {
|
||||||
|
WsMessage,
|
||||||
|
SyncStatePayload,
|
||||||
|
CreatePtyPayload,
|
||||||
|
KillPtyPayload,
|
||||||
|
ResizePayload,
|
||||||
|
ListDirectoriesPayload,
|
||||||
|
} from './connection/protocol.js';
|
||||||
|
import {
|
||||||
|
DIR_PTY_OUTPUT,
|
||||||
|
DIR_USER_INPUT,
|
||||||
|
encodeBinaryFrame,
|
||||||
|
decodeBinaryFrame,
|
||||||
|
} from './connection/protocol.js';
|
||||||
|
import { readdir, stat } from 'fs/promises';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
import { homedir } from 'os';
|
||||||
|
import { existsSync, readdirSync, statSync, readFileSync } from 'fs';
|
||||||
|
|
||||||
|
class ClaudeCliProxy {
|
||||||
|
private ws: WsClient;
|
||||||
|
private pty: PtyManager;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
const config = loadConfig();
|
||||||
|
|
||||||
|
this.pty = new PtyManager();
|
||||||
|
|
||||||
|
this.ws = new WsClient({
|
||||||
|
url: config.serverUrl,
|
||||||
|
token: config.proxyToken,
|
||||||
|
onMessage: (msg) => this.handleMessage(msg),
|
||||||
|
onBinary: (data) => this.handleBinaryFrame(data),
|
||||||
|
onConnected: () => this.onConnected(),
|
||||||
|
onDisconnected: () => this.onDisconnected(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
console.log('[proxy] Starting claude-cli-proxy (xterm mode)...');
|
||||||
|
|
||||||
|
this.ws.connect();
|
||||||
|
|
||||||
|
process.on('SIGINT', () => this.shutdown());
|
||||||
|
process.on('SIGTERM', () => this.shutdown());
|
||||||
|
|
||||||
|
console.log('[proxy] Running. Press Ctrl+C to stop.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private onConnected(): void {
|
||||||
|
console.log('[proxy] Connected to server, waiting for sync...');
|
||||||
|
}
|
||||||
|
|
||||||
|
private onDisconnected(): void {
|
||||||
|
console.log('[proxy] Disconnected from server, PTY sessions continue running');
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Binary frame handling ===
|
||||||
|
|
||||||
|
private handleBinaryFrame(data: Buffer): void {
|
||||||
|
const { direction, chatId, data: payload } = decodeBinaryFrame(data);
|
||||||
|
if (direction === DIR_USER_INPUT) {
|
||||||
|
this.pty.writeToPty(chatId, payload.toString('utf-8'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === JSON message handling ===
|
||||||
|
|
||||||
|
private async handleMessage(msg: WsMessage): Promise<void> {
|
||||||
|
console.log(`[proxy] ← ${msg.type}`);
|
||||||
|
try {
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'sync_state':
|
||||||
|
await this.handleSyncState(msg.payload as SyncStatePayload);
|
||||||
|
break;
|
||||||
|
case 'create_pty':
|
||||||
|
this.handleCreatePty(msg.payload as CreatePtyPayload);
|
||||||
|
break;
|
||||||
|
case 'kill_pty':
|
||||||
|
this.handleKillPty(msg.payload as KillPtyPayload);
|
||||||
|
break;
|
||||||
|
case 'resize':
|
||||||
|
this.handleResize(msg.payload as ResizePayload);
|
||||||
|
break;
|
||||||
|
case 'request_replay' as any: {
|
||||||
|
const { chatId } = msg.payload as { chatId: string };
|
||||||
|
const replay = this.pty.getReplayBuffer(chatId);
|
||||||
|
if (replay) {
|
||||||
|
this.ws.sendBinary(encodeBinaryFrame(DIR_PTY_OUTPUT, chatId, replay));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'list_directories':
|
||||||
|
await this.handleListDirectories(msg.payload as ListDirectoriesPayload, msg.requestId);
|
||||||
|
break;
|
||||||
|
case 'list_sessions':
|
||||||
|
await this.handleListSessions(msg.payload as { workDir: string }, msg.requestId);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.warn(`[proxy] Unknown message type: ${msg.type}`);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`[proxy] Error handling ${msg.type}:`, err);
|
||||||
|
this.ws.send({ type: 'error', payload: { message: err.message } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleSyncState(payload: SyncStatePayload): Promise<void> {
|
||||||
|
console.log(`[proxy] Syncing state: ${payload.chats.length} chats`);
|
||||||
|
|
||||||
|
const activePtys = new Set(this.pty.listPtys());
|
||||||
|
const chatIds = new Set(payload.chats.map((c) => c.id));
|
||||||
|
|
||||||
|
// Only send replay + pty_ready for PTYs that already exist
|
||||||
|
// Do NOT auto-create PTYs — they should only be created via explicit create_pty
|
||||||
|
for (const chat of payload.chats) {
|
||||||
|
if (activePtys.has(chat.id)) {
|
||||||
|
const replay = this.pty.getReplayBuffer(chat.id);
|
||||||
|
if (replay) {
|
||||||
|
this.ws.sendBinary(encodeBinaryFrame(DIR_PTY_OUTPUT, chat.id, replay));
|
||||||
|
}
|
||||||
|
this.ws.send({ type: 'pty_ready', payload: { chatId: chat.id } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kill PTYs that no longer have chats
|
||||||
|
for (const chatId of activePtys) {
|
||||||
|
if (!chatIds.has(chatId)) {
|
||||||
|
this.pty.killPty(chatId);
|
||||||
|
console.log(`[proxy] Killed orphan PTY: ${chatId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleCreatePty(payload: CreatePtyPayload): void {
|
||||||
|
this.pty.createPty(
|
||||||
|
payload.chatId,
|
||||||
|
payload.dir,
|
||||||
|
payload.cols,
|
||||||
|
payload.rows,
|
||||||
|
// onData — PTY output → send to backend
|
||||||
|
(data) => {
|
||||||
|
this.ws.sendBinary(encodeBinaryFrame(DIR_PTY_OUTPUT, payload.chatId, data));
|
||||||
|
},
|
||||||
|
// onExit — PTY exited
|
||||||
|
(exitCode) => {
|
||||||
|
console.log(`[proxy] PTY ${payload.chatId} exited with code ${exitCode}`);
|
||||||
|
this.ws.send({ type: 'pty_closed', payload: { chatId: payload.chatId, exitCode } });
|
||||||
|
},
|
||||||
|
payload.resumeSessionId,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.ws.send({ type: 'pty_ready', payload: { chatId: payload.chatId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleKillPty(payload: KillPtyPayload): void {
|
||||||
|
this.pty.killPty(payload.chatId);
|
||||||
|
this.ws.send({ type: 'pty_closed', payload: { chatId: payload.chatId, exitCode: 0 } });
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleResize(payload: ResizePayload): void {
|
||||||
|
this.pty.resizePty(payload.chatId, payload.cols, payload.rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Directory listing ===
|
||||||
|
|
||||||
|
private async handleListDirectories(payload: ListDirectoriesPayload, requestId?: string): Promise<void> {
|
||||||
|
const dirPath = payload.path.replace(/^~/, process.env.HOME || '');
|
||||||
|
const entries: Array<{ name: string; isDirectory: boolean }> = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const items = await readdir(dirPath);
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.startsWith('.')) continue;
|
||||||
|
try {
|
||||||
|
const itemStat = await stat(resolve(dirPath, item));
|
||||||
|
entries.push({ name: item, isDirectory: itemStat.isDirectory() });
|
||||||
|
} catch {
|
||||||
|
// Skip inaccessible items
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Directory doesn't exist or can't be read
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ws.send({
|
||||||
|
type: 'directories',
|
||||||
|
requestId,
|
||||||
|
payload: { requestId: requestId ?? '', entries },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Session listing ===
|
||||||
|
|
||||||
|
private async handleListSessions(payload: { workDir: string }, requestId?: string): Promise<void> {
|
||||||
|
const expandedDir = (payload.workDir || '').replace(/^~/, homedir());
|
||||||
|
const encoded = expandedDir.replace(/[/_]/g, '-');
|
||||||
|
const projectDir = resolve(homedir(), '.claude', 'projects', encoded);
|
||||||
|
|
||||||
|
const sessions: Array<{ id: string; prompt: string; modified: string; messages: number }> = [];
|
||||||
|
|
||||||
|
if (existsSync(projectDir)) {
|
||||||
|
const files = readdirSync(projectDir).filter((f) => f.endsWith('.jsonl'));
|
||||||
|
|
||||||
|
for (const f of files) {
|
||||||
|
try {
|
||||||
|
const fullPath = resolve(projectDir, f);
|
||||||
|
const s = statSync(fullPath);
|
||||||
|
const data = readFileSync(fullPath, 'utf-8');
|
||||||
|
const lines = data.split('\n').filter(Boolean);
|
||||||
|
|
||||||
|
let firstPrompt = '';
|
||||||
|
let messageCount = 0;
|
||||||
|
for (const line of lines) {
|
||||||
|
try {
|
||||||
|
const j = JSON.parse(line);
|
||||||
|
if (j.type === 'user' || j.type === 'assistant') messageCount++;
|
||||||
|
if (!firstPrompt && j.type === 'user' && j.message?.content) {
|
||||||
|
const c = j.message.content;
|
||||||
|
firstPrompt = typeof c === 'string'
|
||||||
|
? c.substring(0, 80)
|
||||||
|
: (Array.isArray(c) ? (c.find((b: any) => b.type === 'text')?.text?.substring(0, 80) ?? '') : '');
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstPrompt) {
|
||||||
|
sessions.push({
|
||||||
|
id: f.replace('.jsonl', ''),
|
||||||
|
prompt: firstPrompt,
|
||||||
|
modified: new Date(s.mtimeMs).toISOString(),
|
||||||
|
messages: messageCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
sessions.sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ws.send({
|
||||||
|
type: 'sessions_list',
|
||||||
|
requestId,
|
||||||
|
payload: { sessions: sessions.slice(0, 15) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private shutdown(): void {
|
||||||
|
console.log('\n[proxy] Shutting down...');
|
||||||
|
this.pty.killAll();
|
||||||
|
this.ws.disconnect();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const proxy = new ClaudeCliProxy();
|
||||||
|
proxy.start().catch((err) => {
|
||||||
|
console.error('[proxy] Fatal error:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
123
src/pty/manager.ts
Normal file
123
src/pty/manager.ts
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
import * as pty from '@homebridge/node-pty-prebuilt-multiarch';
|
||||||
|
import { homedir } from 'os';
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
|
||||||
|
// Resolve full path to claude CLI
|
||||||
|
function findClaudePath(): string {
|
||||||
|
try {
|
||||||
|
return execSync('which claude', { encoding: 'utf-8' }).trim();
|
||||||
|
} catch {
|
||||||
|
return 'claude'; // fallback to PATH lookup
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const CLAUDE_PATH = findClaudePath();
|
||||||
|
|
||||||
|
const REPLAY_BUFFER_MAX = 50 * 1024; // 50KB per PTY
|
||||||
|
|
||||||
|
interface PtySession {
|
||||||
|
pty: pty.IPty;
|
||||||
|
replayBuffer: Buffer[];
|
||||||
|
replaySize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PtyManager {
|
||||||
|
private sessions = new Map<string, PtySession>();
|
||||||
|
|
||||||
|
createPty(
|
||||||
|
chatId: string,
|
||||||
|
dir: string,
|
||||||
|
cols: number,
|
||||||
|
rows: number,
|
||||||
|
onData: (data: Buffer) => void,
|
||||||
|
onExit: (exitCode: number) => void,
|
||||||
|
resumeSessionId?: string,
|
||||||
|
): void {
|
||||||
|
// Kill existing PTY if any
|
||||||
|
this.killPty(chatId);
|
||||||
|
|
||||||
|
const expandedDir = dir.replace(/^~(?=$|\/)/, homedir());
|
||||||
|
const args = resumeSessionId ? ['--resume', resumeSessionId] : [];
|
||||||
|
|
||||||
|
const cmd = resumeSessionId ? `${CLAUDE_PATH} --resume ${resumeSessionId}` : CLAUDE_PATH;
|
||||||
|
console.log(`[pty] Spawning: /bin/bash -lc "${cmd}" in ${expandedDir}`);
|
||||||
|
const shell = pty.spawn('/bin/bash', ['-lc', cmd], {
|
||||||
|
name: 'xterm-256color',
|
||||||
|
cols,
|
||||||
|
rows,
|
||||||
|
cwd: expandedDir,
|
||||||
|
env: { ...process.env, TERM: 'xterm-256color' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const session: PtySession = {
|
||||||
|
pty: shell,
|
||||||
|
replayBuffer: [],
|
||||||
|
replaySize: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
shell.onData((data: string) => {
|
||||||
|
const buf = Buffer.from(data, 'utf-8');
|
||||||
|
|
||||||
|
// Append to replay buffer
|
||||||
|
session.replayBuffer.push(buf);
|
||||||
|
session.replaySize += buf.length;
|
||||||
|
|
||||||
|
// Trim replay buffer if too large
|
||||||
|
while (session.replaySize > REPLAY_BUFFER_MAX && session.replayBuffer.length > 1) {
|
||||||
|
const removed = session.replayBuffer.shift()!;
|
||||||
|
session.replaySize -= removed.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
onData(buf);
|
||||||
|
});
|
||||||
|
|
||||||
|
shell.onExit(({ exitCode }) => {
|
||||||
|
this.sessions.delete(chatId);
|
||||||
|
onExit(exitCode);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.sessions.set(chatId, session);
|
||||||
|
console.log(`[pty] Created PTY for ${chatId} in ${expandedDir} (${cols}x${rows})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
writeToPty(chatId: string, data: string): void {
|
||||||
|
const session = this.sessions.get(chatId);
|
||||||
|
if (!session) return;
|
||||||
|
session.pty.write(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
resizePty(chatId: string, cols: number, rows: number): void {
|
||||||
|
const session = this.sessions.get(chatId);
|
||||||
|
if (!session) return;
|
||||||
|
session.pty.resize(cols, rows);
|
||||||
|
console.log(`[pty] Resized ${chatId} to ${cols}x${rows}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
killPty(chatId: string): void {
|
||||||
|
const session = this.sessions.get(chatId);
|
||||||
|
if (!session) return;
|
||||||
|
session.pty.kill();
|
||||||
|
this.sessions.delete(chatId);
|
||||||
|
console.log(`[pty] Killed PTY for ${chatId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
getReplayBuffer(chatId: string): Buffer | null {
|
||||||
|
const session = this.sessions.get(chatId);
|
||||||
|
if (!session || session.replayBuffer.length === 0) return null;
|
||||||
|
return Buffer.concat(session.replayBuffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
hasPty(chatId: string): boolean {
|
||||||
|
return this.sessions.has(chatId);
|
||||||
|
}
|
||||||
|
|
||||||
|
listPtys(): string[] {
|
||||||
|
return Array.from(this.sessions.keys());
|
||||||
|
}
|
||||||
|
|
||||||
|
killAll(): void {
|
||||||
|
for (const [chatId] of this.sessions) {
|
||||||
|
this.killPty(chatId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
tsconfig.json
Normal file
10
tsconfig.json
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vigdorov/typescript-config/node",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"module": "node16",
|
||||||
|
"moduleResolution": "node16"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user