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