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:
2026-03-22 00:24:29 +03:00
commit 4a91896732
17 changed files with 6146 additions and 0 deletions

8
.env.example Normal file
View 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
View File

@ -0,0 +1,12 @@
node_modules/
dist/
.env
.env.local
*.log
*.tsbuildinfo
.DS_Store
coverage/
.idea/
.vscode/
.claude/
.serena

1
.npmrc Normal file
View File

@ -0,0 +1 @@
@vigdorov:registry=https://git.vigdorov.ru/api/packages/vigdorov/npm/

1
.prettierrc Normal file
View File

@ -0,0 +1 @@
"@vigdorov/prettier-config"

261
ARCHITECTURE.md Normal file
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,2 @@
import {node} from '@vigdorov/eslint-config';
export default node();

4866
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

32
package.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,10 @@
{
"extends": "@vigdorov/typescript-config/node",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"module": "node16",
"moduleResolution": "node16"
},
"include": ["src"]
}