chore: seed repo with spec, plan, and design
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+437
@@ -0,0 +1,437 @@
|
||||
# spacesh — техническая спецификация
|
||||
|
||||
> Терминал-воркспейс под AI-агентов. macOS-first. Tauri 2 + Rust + React/TypeScript.
|
||||
> Версия документа: v0.1 (handoff-ready). Назначение: спецификация для реализации.
|
||||
|
||||
---
|
||||
|
||||
## 0. Допущения и зафиксированный скоуп
|
||||
|
||||
Документ фиксирует решения, принятые на этапе проектирования. Несколько вещей сознательно **вынесены за рамки v1**:
|
||||
|
||||
- **Учёт токенов и остатков лимитов — НЕ входит в v1.** Решено убрать из уравнения; провайдерские API для остатков грязные/против ToS, а ценность размыта существующими menu-bar трекерами. Может вернуться позже как отдельный подписчик на шину.
|
||||
- **Юридический слой (авторизация пользователей, хранение чужих токенов, ToS-обвязка) — НЕ входит в v1.** Инструмент в первую очередь делается под себя. Вопрос продажи откладывается.
|
||||
- **Встроенный браузер — выкинут.** Большая поверхность поддержки, нулевая ценность для агентского флоу.
|
||||
- **Полноценный редактор (Monaco-класс) — отложен.** В v1 — дерево файлов + diff-просмотр изменений агента (CodeMirror 6), не IDE.
|
||||
|
||||
**Фича «модификации» уточнена как внешние нотификации.** В v1 это рассылка уведомлений о событиях агентов во внешние каналы — **в первую очередь Telegram и MAX**. Реализуется как подписчик внутри демона на события шины (см. §9.2). Guardrails (перехват команд) — не входит в v1.
|
||||
|
||||
---
|
||||
|
||||
## 1. Идея проекта
|
||||
|
||||
### 1.1. Проблема
|
||||
|
||||
Один AI-агент в терминале — управляем глазами. Пять-восемь агентов параллельно в разных проектах — нет: непонятно, кто закончил, кто упал, кто ждёт ввода, к кому возвращаться. Обычный терминал/tmux не сообщает статус сам, раскладки не переживают перезапуск удобно, а единого способа дёрнуть нужную панель из скрипта нет.
|
||||
|
||||
### 1.2. Что такое spacesh
|
||||
|
||||
Терминал-воркспейс, где **бэкенд-демон владеет живыми сессиями**, а GUI и CLI — тонкие клиенты к нему. Воркспейс на проект, реальные PTY со сплитами, видимый статус каждого агента (детерминированный, через хуки агентов), единый CLI поверх той же командной шины. Всё с клавиатуры, macOS-native.
|
||||
|
||||
### 1.3. Ключевые отличия от аналогов (pilotry и пр.)
|
||||
|
||||
1. **Демон переживает GUI.** Краш/обновление приложения не убивает агента в середине задачи. Прямая дорога к remote/SSH-воркспейсам (демон на ноде — GUI на макбуке).
|
||||
2. **macOS-native.** Аналоги Windows-first; spacesh строится сразу под WKWebView, launchd, нативные хоткеи и уведомления.
|
||||
3. **Детерминированный статус** через хуки агентов вместо угадывания по выводу.
|
||||
4. **Единая командная шина** — GUI и CLI идут через один socket, CLI получается почти бесплатно.
|
||||
|
||||
### 1.4. Чем spacesh НЕ является
|
||||
|
||||
Не IDE, не браузер, не замена tmux в смысле мультиплексора для серверов (хотя архитектурно к remote готов), не агент сам по себе — spacesh запускает чужие официальные CLI (Claude Code, Codex, Gemini, opencode, shell) в PTY и не подменяет их логику.
|
||||
|
||||
### 1.5. Целевой пользователь (v1)
|
||||
|
||||
Senior-инфраструктурщик/разработчик, гоняющий несколько агентов параллельно на macOS, скриптующий своё окружение, ценящий клавиатурный флоу и переживаемость сессий.
|
||||
|
||||
---
|
||||
|
||||
## 2. Принципы и нефункциональные требования
|
||||
|
||||
| Принцип | Следствие в реализации |
|
||||
|---|---|
|
||||
| Демон — единственный источник истины | GUI и CLI не хранят состояние; только шлют команды и слушают события |
|
||||
| Keyboard-first | Весь рабочий цикл закрывается с клавиатуры; мышь — только ресайз |
|
||||
| macOS-native | launchd user-agent для демона, нативные уведомления с actions, WKWebView-aware фронт |
|
||||
| Лёгкость | Системный webview (Tauri), без встроенного Chromium |
|
||||
| Переживаемость | Сессии живут в демоне между перезапусками GUI |
|
||||
| Расширяемость через шину | Новые фичи = новые команды/события/подписчики, спина не двигается |
|
||||
|
||||
**Бюджеты производительности:**
|
||||
|
||||
- Латентность ввода (keypress → echo в панели): < 16 мс при нормальной нагрузке.
|
||||
- Батчинг вывода PTY: коалесцировать ~4–8 мс **или** до ~16 КБ, что раньше; не эмитить событие `output` по байту.
|
||||
- Backpressure: при отставании рендера демон не должен расти в памяти неограниченно — кольцевой лимит на неподтверждённый поток к клиенту.
|
||||
- Repaint при reattach: снапшот экрана панели < 100 мс на типовой грид.
|
||||
|
||||
---
|
||||
|
||||
## 3. Архитектура — топология
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ spacesh-daemon (spaceshd) │
|
||||
│ владеет: │
|
||||
│ • PTY-процессы (по surface) │
|
||||
│ • терминальный грид каждой панели (alacritty_terminal)│
|
||||
│ • модель воркспейсов / дерево сплитов / раскладки │
|
||||
│ • движок статусов │
|
||||
│ • подписчики (нотификации Telegram/MAX) │
|
||||
│ │
|
||||
│ хостит: Unix-domain socket ~/.spacesh/sock │
|
||||
└───────────────┬───────────────────────┬──────────────────┘
|
||||
│ │
|
||||
attach/команды/события команды/события (--json)
|
||||
│ │
|
||||
┌──────────┴─────────┐ ┌────────┴───────────┐
|
||||
│ GUI-клиент (Tauri) │ │ CLI-клиент (spacesh)│
|
||||
│ рендер xterm.js │ │ тонкий бинарь │
|
||||
│ ввод, attach/det. │ │ shell-completions │
|
||||
└────────────────────┘ └─────────────────────┘
|
||||
```
|
||||
|
||||
Один socket, один протокол. И клик в GUI, и `spacesh focus s_8f3` из скрипта — это одна и та же команда `focus`. Никакой дубликации операционной логики.
|
||||
|
||||
---
|
||||
|
||||
## 4. Процессная модель (вариант B — демон)
|
||||
|
||||
### 4.1. Жизненный цикл демона
|
||||
|
||||
- Демон стартует лениво: первый клиент (GUI или CLI), не найдя socket, поднимает `spaceshd` и ждёт готовности.
|
||||
- Под macOS оборачивается в **launchd user-agent** (`~/Library/LaunchAgents/xyz.spacesh.daemon.plist`) с `KeepAlive` — автоперезапуск при краше, автостарт при логине (опционально, по настройке).
|
||||
- Демон single-instance: лок-файл `~/.spacesh/daemon.lock` + проверка живости socket.
|
||||
- Graceful shutdown по команде `shutdown` или при отсутствии сессий и клиентов дольше TTL (настраиваемо; по умолчанию демон живёт, пока живы сессии).
|
||||
|
||||
### 4.2. Переживаемость сессий
|
||||
|
||||
PTY-процессы принадлежат демону, а не GUI. Закрытие/краш/обновление GUI **не убивает** агентов. При следующем запуске GUI выполняет `attach` к живым панелям и репейнтит их из снапшота грида (см. §6.4).
|
||||
|
||||
Раскладка (определение воркспейсов, дерево сплитов, привязка агентов к панелям) персистится на диск отдельно от живых процессов (§8.4) — так что даже полная перезагрузка демона восстанавливает структуру (с перезапуском агентов), а краш GUI восстанавливает и структуру, и живые процессы.
|
||||
|
||||
### 4.3. Эволюция к remote (вне v1, но заложено)
|
||||
|
||||
Socket абстрагирован за трейтом транспорта. UDS локально сегодня → проброс через SSH (`ssh -L` к UDS на ноде) завтра → нативный TCP+mTLS позже. Демон не знает, локальный клиент или удалённый.
|
||||
|
||||
---
|
||||
|
||||
## 5. Протокол командной шины
|
||||
|
||||
### 5.1. Транспорт и кадрирование
|
||||
|
||||
- Транспорт: Unix-domain socket, асинхронный (tokio).
|
||||
- Кадрирование: **length-prefixed** (`u32` BE длина + payload).
|
||||
- Сериализация: **JSON** в v1 (читаемость, отладка, `--json` для CLI). Возможна замена на MessagePack при упоре в объём вывода — изолировано в `spacesh-proto`.
|
||||
- Мультиплексирование: один socket на клиента; внутри — корреляция запрос/ответ по `id`, плюс независимый поток push-событий.
|
||||
|
||||
### 5.2. Конверт сообщения
|
||||
|
||||
```jsonc
|
||||
// Запрос (клиент → демон)
|
||||
{ "id": 42, "kind": "req", "cmd": "focus", "args": { "surface_id": "s_8f3" } }
|
||||
|
||||
// Ответ (демон → клиент), коррелируется по id
|
||||
{ "id": 42, "kind": "res", "ok": true, "data": { /* ... */ } }
|
||||
{ "id": 42, "kind": "res", "ok": false, "error": { "code": "NOT_FOUND", "msg": "..." } }
|
||||
|
||||
// Событие (демон → подписчики), без id запроса
|
||||
{ "kind": "evt", "evt": "state", "data": { "surface_id": "s_8f3", "state": "done" } }
|
||||
```
|
||||
|
||||
### 5.3. Команды (request/response)
|
||||
|
||||
| Команда | Аргументы | Ответ | Назначение |
|
||||
|---|---|---|---|
|
||||
| `open` | `{ path }` | `{ workspace_id }` | Открыть директорию воркспейсом; повторный вызов не плодит дубль |
|
||||
| `close_workspace` | `{ workspace_id }` | `{}` | Закрыть воркспейс (с подтверждением при живых панелях) |
|
||||
| `new_surface` | `{ workspace_id, agent, split? }` | `{ surface_id }` | Поднять панель с агентом; `split: right\|down` — сразу сплитом |
|
||||
| `close` | `{ surface_id }` | `{}` | Закрыть панель |
|
||||
| `focus` | `{ surface_id }` | `{}` | Сфокусировать панель и поднять окно |
|
||||
| `input` | `{ surface_id, bytes }` | `{}` | Ввод с клавиатуры (base64 в JSON) |
|
||||
| `resize` | `{ surface_id, cols, rows }` | `{}` | Ресайз PTY (→ SIGWINCH) |
|
||||
| `attach` | `{ surface_id }` | `{ snapshot, cols, rows, cursor }` | Подписаться на панель + получить снапшот для репейнта |
|
||||
| `detach` | `{ surface_id }` | `{}` | Отписаться от потока панели |
|
||||
| `status` | `{ json? }` | `{ workspaces[] }` | Список воркспейсов и панелей с состояниями |
|
||||
| `set_state` | `{ surface_id, state }` | `{}` | Внешний сигнал статуса (от хуков агента, см. §7) |
|
||||
| `move_surface` | `{ surface_id, target }` | `{}` | Перетаскивание/переупорядочивание |
|
||||
| `apply_preset` | `{ workspace_id, preset_id }` | `{ surface_ids[] }` | Развернуть пресет раскладки |
|
||||
| `shutdown` | `{}` | `{}` | Остановить демон |
|
||||
|
||||
### 5.4. События (push)
|
||||
|
||||
| Событие | Данные | Назначение |
|
||||
|---|---|---|
|
||||
| `output` | `{ surface_id, bytes }` | Живой поток вывода PTY для рендера (батчированный) |
|
||||
| `state` | `{ surface_id, state }` | Смена статуса: `done\|wait\|work\|error\|idle` |
|
||||
| `exit` | `{ surface_id, code }` | Процесс панели завершился |
|
||||
| `surface_created` | `{ surface_id, workspace_id, agent }` | Появилась панель |
|
||||
| `surface_closed` | `{ surface_id }` | Панель закрыта |
|
||||
|
||||
### 5.5. Жизненный цикл attach/detach (сердце варианта B)
|
||||
|
||||
```
|
||||
GUI стартует
|
||||
→ status // получить структуру
|
||||
→ для каждой видимой панели: attach { surface_id }
|
||||
← res { snapshot, cursor } // репейнт xterm.js из снапшота
|
||||
← evt output ... // дальше живой поток
|
||||
GUI сворачивается/закрывается
|
||||
→ detach (или просто рвёт соединение — демон чистит подписку по closed-socket)
|
||||
GUI возвращается
|
||||
→ attach снова → новый снапшот → живой поток
|
||||
```
|
||||
|
||||
Снапшот собирается демоном из Rust-грида (`alacritty_terminal`) и сериализуется в ANSI-дамп, который xterm.js скармливает себе для мгновенного репейнта. Это закрывает главную боль демон-архитектуры — восстановление экрана панели после reattach — без отдельного ring-buffer.
|
||||
|
||||
---
|
||||
|
||||
## 6. Терминальный слой
|
||||
|
||||
### 6.1. PTY
|
||||
|
||||
- Крейт: **`portable-pty`** (спавн, master/slave, resize). Кроссплатформенно — пригодится, даже если v1 только macOS.
|
||||
- На surface: один PTY + дочерний процесс (агентский CLI или shell), рабочая директория = папка воркспейса, прокинутый env.
|
||||
|
||||
### 6.2. Reader-loop, батчинг, backpressure
|
||||
|
||||
- Отдельная async-задача на PTY читает в буфер.
|
||||
- **Коалесцирование:** накапливать вывод и эмитить `output` раз в ~4–8 мс или по достижении ~16 КБ. Билд-лог на 50k строк не должен порождать 50k событий.
|
||||
- **Backpressure:** на каждого подписчика — ограниченный канал. При переполнении (рендер не успевает) — дропать/сжимать промежуточные кадры, но грид в демоне (§6.3) обновлять всегда — он авторитетный.
|
||||
|
||||
### 6.3. Гибрид-парсинг (xterm.js рендер / Rust-грид семантика)
|
||||
|
||||
Один байтовый поток PTY раздаётся в **три** места:
|
||||
|
||||
1. **GUI** (событие `output`) → **xterm.js** парсит ANSI и рисует. Аддоны: `webgl` (рендер), `search`, `serialize`, `web-links`.
|
||||
2. **Rust-грид** (`alacritty_terminal`) в демоне → авторитетная модель экрана/скроллбэка. Используется для:
|
||||
- детекции статуса (fallback-паттерны, §7.3),
|
||||
- поиска по скроллбэку на стороне демона,
|
||||
- извлечения вывода последней команды (по маркерам OSC 133),
|
||||
- снапшота при reattach (§6.4).
|
||||
3. Тот же грид служит источником снапшота — отдельный буфер не нужен.
|
||||
|
||||
Развод **отображения** (xterm.js) и **анализа** (alacritty-грид) снимает ложный выбор «или рендер, или умная модель». Два парсера на один stream дёшево.
|
||||
|
||||
> Выбор `alacritty_terminal` (а не голого `vte`): он держит полноценный грид с курсором и скроллбэком из коробки, что нужно и для семантики, и для снапшота. `vte` дал бы только парсинг esc-последовательностей без модели экрана.
|
||||
|
||||
### 6.4. Снапшот и репейнт
|
||||
|
||||
При `attach` демон сериализует текущий грид панели → ANSI-дамп (видимая область + позиция курсора + при необходимости часть скроллбэка) → GUI пишет его в свежий инстанс xterm.js. Дальше едет живой `output`. Бюджет — см. §2.
|
||||
|
||||
---
|
||||
|
||||
## 7. Детекция статуса (инверсия)
|
||||
|
||||
Статус не угадывается из вывода, а **приходит в демон как команда** `set_state`. Источники, по убыванию надёжности:
|
||||
|
||||
### 7.1. Хуки агентов (основной путь)
|
||||
|
||||
- **Claude Code** поддерживает хуки (Stop / Notification и родственные). Вешаем на них вызов CLI:
|
||||
```
|
||||
spacesh notify --surface $SPACESH_SURFACE_ID --state done # на завершение
|
||||
spacesh notify --surface $SPACESH_SURFACE_ID --state wait # на запрос ввода
|
||||
```
|
||||
CLI шлёт `set_state` в socket → демон ставит статус → событие `state` → кольцо панели меняет цвет. Детерминированно.
|
||||
- `$SPACESH_SURFACE_ID` инжектируется в env панели при спавне — так хук знает, про какую панель сообщает.
|
||||
|
||||
> **Внимание при реализации:** точные имена хуков и формат payload у Claude Code меняются между версиями. Слой интеграции с хуками изолировать в адаптер и версионировать; не зашивать имена в ядро.
|
||||
|
||||
### 7.2. OSC 133 для shell
|
||||
|
||||
Для обычных shell-панелей инжектировать shell-integration с **OSC 133** (semantic prompt marking): чёткие маркеры «команда началась / закончилась + exit code». Даёт статус (`work` между A/C-маркерами, `done`/`error` по exit code), а заодно навигацию по промптам и извлечение вывода последней команды.
|
||||
|
||||
### 7.3. Fallback — паттерны вывода
|
||||
|
||||
Для агентов без хуков — эвристики поверх alacritty-грида (спиннеры, промпт-паттерны, запросы подтверждения). Изолировано в один модуль, считается best-effort. Меньшинство кейсов.
|
||||
|
||||
### 7.4. Состояния
|
||||
|
||||
`work` (работает) · `wait` (ждёт ввода) · `done` (готов) · `error` (ошибка) · `idle` (простаивает). Маппятся на цвет кольца панели и бейдж в сайдбаре.
|
||||
|
||||
---
|
||||
|
||||
## 8. Модель воркспейсов и раскладки
|
||||
|
||||
### 8.1. Сущности
|
||||
|
||||
- **Workspace** — папка проекта + дерево сплитов + метаданные (имя, цвет, группа, флаг «не забыть»).
|
||||
- **Surface (панель)** — лист дерева сплитов; PTY + агент + состояние.
|
||||
- **Split node** — внутренний узел: ориентация (`h`/`v`) + дети + пропорции.
|
||||
- **Group** — именованная цветная коллекция воркспейсов с порядком.
|
||||
|
||||
### 8.2. Дерево сплитов
|
||||
|
||||
Рекурсивное бинарное (или n-арное) дерево. Ресайз — изменение пропорций узла. 10 пресет-раскладок: `1`, `2↔`, `2↕`, `2+1`, `1+2`, `3`, `2×2`, `4`, `2×3`, `2×4`.
|
||||
|
||||
### 8.3. Пресеты
|
||||
|
||||
Пресет = (раскладка панелей) + (привязка агента к каждой панели). Сохранённый пресет → запуск в один клик (`apply_preset`). Хранится в конфиге пользователя.
|
||||
|
||||
### 8.4. Персистентность
|
||||
|
||||
- Раскладка и метаданные — на диск (`~/.spacesh/state.json` или SQLite, если вырастет), отдельно от живых процессов.
|
||||
- Сохранение по изменению (debounce), атомарная запись (temp + rename).
|
||||
- При старте демона без живых сессий — структура восстанавливается, агенты перезапускаются по запросу/настройке.
|
||||
|
||||
### 8.5. UX-метаданные (дёшево, эффект большой)
|
||||
|
||||
- **Цветные группы** воркспейсов.
|
||||
- **Метка «не забыть»** — unread-модель как в почте: кружок на воркспейсе, пока к нему не вернулись.
|
||||
- **Свой порядок** — перетаскивание, раскладка сохраняется.
|
||||
- **Центр событий** — лента уведомлений (готов/ждёт/ошибка) с «отметить прочитанным».
|
||||
|
||||
---
|
||||
|
||||
## 9. Фичи поверх ядра (v1)
|
||||
|
||||
### 9.1. Зум-панели
|
||||
|
||||
Временный фуллскрин одной панели поверх раскладки. Чисто **UI-состояние GUI** — дерево сплитов не трогается, демона не касается. Хоткей-тоггл, по выходу возврат к прежней раскладке.
|
||||
|
||||
### 9.2. Внешние нотификации (Telegram, MAX)
|
||||
|
||||
Когда окно свёрнуто или ты не за макбуком, событие агента должно догнать снаружи. Реализуется как **подписчик внутри `spaceshd`** на события шины — спине это ничего не двигает, чистое расширение.
|
||||
|
||||
**Механика:**
|
||||
|
||||
- Подписчик слушает события `state` (по умолчанию реагирует на `done`, `wait`, `error`; набор настраивается) и `exit`.
|
||||
- На совпадении формирует сообщение (`воркспейс · агент · состояние`, время, опционально хвост вывода) и диспетчит во включённые каналы.
|
||||
- Каждый канал — отдельный **адаптер** за общим трейтом `NotificationChannel { async fn send(&self, msg) }`. Добавить новый канал = новый адаптер, ядро не трогается.
|
||||
|
||||
**Адаптеры v1:**
|
||||
|
||||
| Канал | Транспорт | Конфиг | Заметки |
|
||||
|---|---|---|---|
|
||||
| **Telegram** | Bot API, HTTPS `sendMessage` | `bot_token`, `chat_id` | Бот создаётся через @BotFather. Только исходящие — webhook/long-poll не нужны |
|
||||
| **MAX** | MAX Bot API (`dev.max.ru`), HTTPS, JSON | `bot_token`, `chat_id` | Токен от Master Bot. Авторизация заголовком `Authorization: <token>` (query-параметр устарел). Только исходящие |
|
||||
|
||||
> MAX Bot API построен по тем же принципам, что Telegram (токен → HTTPS-запросы → JSON), но детали отличаются: тип апдейтов в явном поле `update_type`, свой набор методов и формат сообщений. Для нотификаций нам нужен только метод отправки сообщения — приём апдейтов/webhook не требуется. Есть официальный TS-клиент `@maxhub/max-bot-api` и python-клиенты, если не захочется бить по HTTP напрямую.
|
||||
|
||||
**Поведение:**
|
||||
|
||||
- **Анти-спам / дедуп:** не слать повторное уведомление об одном и том же состоянии панели; коалесцировать всплески (несколько `done` подряд → одно сводное). Окно дедупа настраивается.
|
||||
- **Rate limits:** соблюдать лимиты каналов (у MAX базово ~30 msg/s на бота; Telegram ~30 msg/s глобально, ~1 msg/s на чат). Для персонального инструмента не критично, но очередь с троттлингом заложить.
|
||||
- **Фильтр по воркспейсу/группе:** опционально слать только по помеченным воркспейсам (увязывается с меткой «не забыть», §8.5).
|
||||
- **Доставка best-effort:** сбой канала логируется, ретраится ограниченно, не блокирует работу демона.
|
||||
|
||||
**Конфиг (пример, `~/.spacesh/config.toml`):**
|
||||
|
||||
```toml
|
||||
[notifications]
|
||||
on_states = ["done", "wait", "error"]
|
||||
dedup_window_secs = 30
|
||||
|
||||
[notifications.telegram]
|
||||
enabled = true
|
||||
bot_token = "env:SPACESH_TG_TOKEN"
|
||||
chat_id = "123456789"
|
||||
|
||||
[notifications.max]
|
||||
enabled = true
|
||||
bot_token = "env:SPACESH_MAX_TOKEN"
|
||||
chat_id = "..."
|
||||
```
|
||||
|
||||
> Токены — через ссылку на env-переменную, не хранить в открытом виде в конфиге.
|
||||
|
||||
---
|
||||
|
||||
## 10. Структура кода
|
||||
|
||||
### 10.1. Rust workspace (cargo)
|
||||
|
||||
```
|
||||
spacesh/
|
||||
├── crates/
|
||||
│ ├── spacesh-proto/ # типы команд/событий, serde, кадрирование. Шарится всеми.
|
||||
│ ├── spacesh-core/ # модель воркспейсов/сплитов, грид (alacritty_terminal),
|
||||
│ │ # движок статусов. Без I/O — юнит-тестируемо.
|
||||
│ ├── spacesh-pty/ # спавн/io PTY (portable-pty), reader-loop, батчинг, resize.
|
||||
│ ├── spaceshd/ # демон: владеет сессиями, хостит socket, fan-out событий,
|
||||
│ │ # персист раскладки, launchd-обвязка, подписчики.
|
||||
│ └── spacesh-cli/ # тонкий бинарь-клиент к socket.
|
||||
└── app/ # Tauri-приложение
|
||||
├── src-tauri/ # Rust-сторона: клиент к socket + мост в webview
|
||||
└── src/ # React/TS фронт
|
||||
```
|
||||
|
||||
| Крейт | Ответственность | Ключевые зависимости |
|
||||
|---|---|---|
|
||||
| `spacesh-proto` | Протокол: типы, serde, length-prefix framing | `serde`, `serde_json` |
|
||||
| `spacesh-core` | Доменная модель, грид, статусы | `alacritty_terminal` |
|
||||
| `spacesh-pty` | PTY io, батчинг, backpressure | `portable-pty`, `tokio` |
|
||||
| `spaceshd` | Демон, socket-сервер, fan-out, персист, подписчики-нотификации (Telegram/MAX) | `tokio`, `reqwest`, `spacesh-*` |
|
||||
| `spacesh-cli` | CLI-клиент | `clap`, `tokio` |
|
||||
| `app/src-tauri` | Мост socket ↔ webview | `tauri` 2, `tokio` |
|
||||
|
||||
### 10.2. React/TS фронт
|
||||
|
||||
| Компонент | Ответственность |
|
||||
|---|---|
|
||||
| `TerminalView` | xterm.js + аддоны (webgl/search/serialize/links) на панель; ввод → `input`, репейнт из снапшота |
|
||||
| `LayoutEngine` | рекурсивное дерево сплитов, ресайз мышью, **зум-панели** |
|
||||
| `Sidebar` | воркспейсы/группы, цвета, метка «не забыть», бейджи статуса |
|
||||
| `EventCenter` | лента уведомлений, «отметить прочитанным» |
|
||||
| `Wizard` | новый воркспейс (папка+раскладка→агент в панель), пресеты |
|
||||
| `Settings` | конфиг нотификаций (Telegram/MAX), хоткеи, правила; редактирование `config.toml` |
|
||||
| `socketBridge` | подписка на события демона через Tauri IPC, отправка команд |
|
||||
|
||||
---
|
||||
|
||||
## 11. Хоткеи (черновой набор)
|
||||
|
||||
| Действие | Хоткей |
|
||||
|---|---|
|
||||
| Новый воркспейс | `Cmd + N` |
|
||||
| Переключить воркспейс | `Cmd + 1…9` |
|
||||
| Сплит панели | `Cmd + Shift + T` |
|
||||
| К свежему агенту | `Cmd + Shift + U` |
|
||||
| Соседняя панель | `Cmd + Opt + ← ↑ → ↓` |
|
||||
| Зум панели (тоггл) | `Cmd + Shift + Z` |
|
||||
| Поиск по скроллбэку | `Cmd + F` |
|
||||
| Настройки | `Cmd + ,` |
|
||||
|
||||
> macOS-маппинг (Cmd вместо Ctrl). Сверить конфликты с системными/агентскими хоткеями; вынести в настраиваемую схему.
|
||||
|
||||
---
|
||||
|
||||
## 12. Порядок реализации (milestones)
|
||||
|
||||
**M0 — живой терминал через socket (вертикальный срез).**
|
||||
`spacesh-proto` + `spaceshd` с командами `open` / `new_surface` / `input` / `output` + реальный PTY (`spacesh-pty`). Цель: байты летают GUI ↔ демон ↔ PTY. Минимальный фронт с одним `TerminalView`.
|
||||
|
||||
**M1 — переживаемость и reattach.**
|
||||
Грид в демоне (`spacesh-core` + alacritty), `attach`/`detach` со снапшотом, репейнт xterm.js. launchd-обвязка. Цель: убил GUI — агент жив, открыл — экран восстановился.
|
||||
|
||||
**M2 — раскладки и воркспейсы.**
|
||||
`LayoutEngine`, дерево сплитов, ресайз, персист (`state.json`), сайдбар с воркспейсами. Пресеты + `apply_preset`. Wizard.
|
||||
|
||||
**M3 — статусы.**
|
||||
`set_state`, интеграция хуков Claude Code, OSC 133 для shell, fallback-паттерны. Кольца + бейджи + центр событий + нативные уведомления.
|
||||
|
||||
**M4 — CLI.**
|
||||
`spacesh-cli` поверх готовой шины: `open`/`status`/`focus`/`close`/`new-surface`/`notify`, `--json`, shell-completions.
|
||||
|
||||
**M5 — фичи поверх.**
|
||||
Зум-панели, внешние нотификации (Telegram + MAX, §9.2), поиск по скроллбэку, diff-просмотр изменений агента (CodeMirror 6).
|
||||
|
||||
**M6 (post-v1) — remote.**
|
||||
Транспорт-трейт → проброс socket через SSH → демон на ноде, GUI на макбуке.
|
||||
|
||||
---
|
||||
|
||||
## 13. Открытые вопросы и риски
|
||||
|
||||
| # | Вопрос/риск | Заметка |
|
||||
|---|---|---|
|
||||
| 1 | Имена/формат хуков Claude Code дрейфуют по версиям | Изолировать в версионируемый адаптер; не зашивать в ядро |
|
||||
| 2 | Нотификации: блокировка бота за массовые рассылки/спам (особенно MAX) | Только исходящие, низкий объём, дедуп + троттлинг; не использовать для рассылок |
|
||||
| 3 | WKWebView ≠ WebView2: IME (рус/англ), фокус, DnD, перехват клавиш | Отлаживать на macOS с нуля, не переносить допущения с Windows-аналогов |
|
||||
| 4 | Производительность xterm.js при шквальном выводе | webgl-аддон обязателен; батчинг §6.2; профилировать на реальных билд-логах |
|
||||
| 5 | Подпись/нотаризация для раздачи (Developer ID, stapling) | Заложить в CI заранее, иначе Gatekeeper ломает «скачал и запустил» |
|
||||
| 6 | Автообновление вне App Store | Tauri updater / Sparkle; решить на M1 |
|
||||
| 7 | Single-instance демона и гонки при старте | Лок-файл + проверка живости socket; обработать «socket есть, демон мёртв» |
|
||||
| 8 | Объём `output` по JSON-socket | Если упрёмся — MessagePack, изменение локализовано в `spacesh-proto` |
|
||||
|
||||
---
|
||||
|
||||
*Конец спецификации v0.1.*
|
||||
+8697
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,204 @@
|
||||
# spacesh M0+M1 — design spec
|
||||
|
||||
> Срез реализации: **M0 (живой терминал через socket)** + **M1 (переживаемость и reattach)**.
|
||||
> Базовая спецификация: `DOCS/MAIN.md` (v0.1). Этот документ сужает её до первого реализуемого среза.
|
||||
> Дата: 2026-06-09.
|
||||
|
||||
---
|
||||
|
||||
## 1. Скоуп среза
|
||||
|
||||
**Входит:**
|
||||
- Cargo workspace, крейты `spacesh-proto`, `spacesh-core`, `spacesh-pty`, `spaceshd`.
|
||||
- Tauri 2 приложение `app/` с одной панелью `TerminalView` (xterm.js) и переключением между surfaces.
|
||||
- Шина команд/событий поверх UDS (`~/.spacesh/sock`), субсет протокола (см. §4).
|
||||
- Реальный PTY, батчированный поток вывода, ввод с клавиатуры.
|
||||
- Грид в демоне (`alacritty_terminal`), снапшот→ANSI, `attach`/`detach`, репейнт после reattach.
|
||||
- launchd user-agent с `KeepAlive`; ленивый старт; single-instance.
|
||||
|
||||
**Зафиксированные решения (из брейншторма):**
|
||||
- `new_surface` запускает **произвольную команду; дефолт = login shell** (`$SHELL`). `$SPACESH_SURFACE_ID` инжектируется в env панели.
|
||||
- Переживаемость = **только live-reattach** (демон переживает GUI). Диск-персист раскладки и дерево сплитов — отложены в M2.
|
||||
- GUI = **одна панель + переключение между surfaces** (несколько surfaces живут в демоне, GUI делает `attach` к одной). LayoutEngine/сплиты — M2.
|
||||
- Мост GUI↔демон = **схема B**: Tauri `Channel` для `output`, `invoke` для команд, `emit` для редких событий.
|
||||
|
||||
**Не входит (явно):**
|
||||
- Статусы / `set_state` / хуки агентов / OSC 133 / fallback-паттерны — **M3**.
|
||||
- Дерево сплитов, `move_surface`, `apply_preset`, `close_workspace`, диск-персист раскладки (`state.json`) — **M2**.
|
||||
- `spacesh-cli` — **M4**. Нотификации Telegram/MAX, зум, поиск, diff-view — **M5**. Remote — **M6**.
|
||||
- Поле `split?` у `new_surface`, скроллбэк в снапшоте.
|
||||
|
||||
---
|
||||
|
||||
## 2. Архитектура и крейты
|
||||
|
||||
```
|
||||
spacesh/
|
||||
├── Cargo.toml # workspace
|
||||
├── crates/
|
||||
│ ├── spacesh-proto/ # типы Envelope/Cmd/Evt, serde, length-prefix framing (u32 BE + payload)
|
||||
│ ├── spacesh-core/ # SurfaceId/WorkspaceId, грид (alacritty_terminal), снапшот→ANSI. NO I/O.
|
||||
│ ├── spacesh-pty/ # spawn PTY (portable-pty), reader-loop, батчинг, resize, backpressure
|
||||
│ └── spaceshd/ # демон: socket-сервер, реестр сессий, fan-out, attach/snapshot, launchd
|
||||
└── app/
|
||||
├── src-tauri/ # UDS-клиент + мост (Channel/invoke/emit), bridge.rs
|
||||
└── src/ # React/TS: TerminalView, SurfaceList, socketBridge, App
|
||||
```
|
||||
|
||||
| Крейт | Ответственность | Ключевые зависимости | Тестируемость |
|
||||
|---|---|---|---|
|
||||
| `spacesh-proto` | Протокол: типы + кадрирование. Чистый, шарится всеми. | serde, serde_json, tokio (codec), bytes | юнит: round-trip encode/decode, кадрирование |
|
||||
| `spacesh-core` | Доменные id, грид-обёртка, снапшот→ANSI-дамп. Без I/O. | alacritty_terminal | юнит: feed bytes → детерминированный snapshot |
|
||||
| `spacesh-pty` | PTY io, reader-loop, коалесцирование (4–8мс/16КБ), resize, backpressure | portable-pty, tokio, bytes | интеграц: spawn `echo`, читаем вывод |
|
||||
| `spaceshd` | Реестр Surface, socket accept, корреляция req/res, fan-out evt, attach=snapshot+stream, launchd | tokio, spacesh-* | интеграц: connect→new_surface→input→output |
|
||||
| `app/src-tauri` | Мост UDS↔webview по схеме B | tauri 2, spacesh-proto, tokio | ручная проверка |
|
||||
|
||||
`spacesh-core` намеренно без I/O — грид и снапшот юнит-тестируемы фидом байтов.
|
||||
|
||||
---
|
||||
|
||||
## 3. Мост GUI↔демон (схема B)
|
||||
|
||||
`src-tauri` держит UDS-коннект к демону и разводит трафик по частоте:
|
||||
|
||||
- **`output` (высокочастотный)** → `tauri::ipc::Channel<OutputChunk>` на surface. Типизированный стрим, дешевле общей IPC-шины, естественный backpressure на канал. JS получает чанки и пишет в `xterm.write()`.
|
||||
- **Команды** (`open`/`new_surface`/`input`/`resize`/`attach`/`detach`/`focus`/`close`/`status`/`shutdown`) → `#[tauri::command]` + `invoke` из JS. Rust кадрирует req, ждёт res по `id`, возвращает в JS.
|
||||
- **Редкие события** (`exit`/`surface_created`/`surface_closed`) → `app.emit(...)`, JS слушает.
|
||||
|
||||
Прямой WebSocket JS→демон отклонён: ломает «один socket/UDS», добавляет транспорт и кадрирование в JS.
|
||||
|
||||
---
|
||||
|
||||
## 4. Субсет протокола (M0+M1)
|
||||
|
||||
Конверт из `DOCS/MAIN.md` §5.2 без изменений: `req`/`res` (корреляция по `id`) + `evt` (push, без id). Кадрирование: `u32` BE длина + JSON payload.
|
||||
|
||||
### Команды
|
||||
|
||||
| Команда | Args | Res | Назначение |
|
||||
|---|---|---|---|
|
||||
| `open` | `{ path }` | `{ workspace_id }` | открыть папку воркспейсом; повтор не плодит дубль |
|
||||
| `new_surface` | `{ workspace_id, cmd?, args?, cols, rows }` | `{ surface_id }` | поднять PTY; пусто→`$SHELL`; cwd=папка воркспейса; `$SPACESH_SURFACE_ID` в env |
|
||||
| `input` | `{ surface_id, bytes }` | `{}` | ввод (base64 в JSON) → PTY master |
|
||||
| `resize` | `{ surface_id, cols, rows }` | `{}` | ресайз PTY → SIGWINCH |
|
||||
| `attach` | `{ surface_id }` | `{ snapshot, cols, rows, cursor }` | подписка на поток + ANSI-снапшот для репейнта |
|
||||
| `detach` | `{ surface_id }` | `{}` | отписка от потока |
|
||||
| `focus` | `{ surface_id }` | `{}` | пометить активной (паритет с CLI позже) |
|
||||
| `close` | `{ surface_id }` | `{}` | убить PTY, закрыть панель |
|
||||
| `status` | `{}` | `{ workspaces[] }` | структура при старте GUI |
|
||||
| `shutdown` | `{}` | `{}` | остановить демон |
|
||||
|
||||
### События (push)
|
||||
|
||||
| Событие | Data | Назначение |
|
||||
|---|---|---|
|
||||
| `output` | `{ surface_id, bytes }` | живой батчированный поток вывода PTY (едет каналом, схема B) |
|
||||
| `exit` | `{ surface_id, code }` | процесс панели завершился |
|
||||
| `surface_created` | `{ surface_id, workspace_id }` | появилась панель |
|
||||
| `surface_closed` | `{ surface_id }` | панель закрыта |
|
||||
|
||||
`attach` возвращает снапшот в `res`; дальше живой `output` приходит потоком. Порядок гарантирован актором (см. §5).
|
||||
|
||||
---
|
||||
|
||||
## 5. Модель владения и потоки данных
|
||||
|
||||
**Actor-per-surface.** Каждый Surface — отдельная tokio-задача, единолично владеющая PTY master, дочерним процессом, alacritty-гридом, набором подписчиков. Команды к surface — через `mpsc`; вывод — через `broadcast`. Single-task = единственный писатель в грид: гонок нет by design, и это же гарантирует корректный порядок attach. Общий `HashMap` под `RwLock` отклонён (конкуренция за грид, гонки на attach).
|
||||
|
||||
```
|
||||
spaceshd
|
||||
├── accept-loop # на коннект → client-task
|
||||
├── client-task (на клиента) # читает кадры, корреляция id, маршрут команд
|
||||
├── registry # SurfaceId → mpsc::Sender<SurfaceMsg>, WorkspaceId → meta
|
||||
└── surface-actor (на surface) # select! { команды-mpsc | PTY-reader-rx | flush-таймер }
|
||||
├── owns: PtyMaster, Child, Term(grid), broadcast::Sender
|
||||
└── PTY-reader-task → сырьё в актор
|
||||
```
|
||||
|
||||
**Input:** client-task → `input` → registry → surface mpsc → write в PTY master.
|
||||
|
||||
**Output:** PTY-reader читает → актор коалесцирует (флаш по таймеру 4–8мс **или** при накоплении 16КБ) → на флаше: (1) `term.feed(bytes)` — авторитетный грид; (2) `broadcast.send(bytes)` подписчикам → каждый подписчик в своей client-task кадрирует `output` evt → socket → src-tauri reader → Tauri Channel → `xterm.write()`.
|
||||
|
||||
**Backpressure:** `broadcast` ограниченной ёмкости; отстающий подписчик получает `Lagged` и теряет промежуточные кадры, но грид всегда целый — следующий кадр сводит экран. Демон не растёт в памяти неограниченно.
|
||||
|
||||
**Attach / reattach (ядро M1):**
|
||||
```
|
||||
client → attach{surface_id}
|
||||
surface-actor обрабатывает СИНХРОННО в своём loop:
|
||||
1. rx = broadcast.subscribe() // подписка ПЕРВОЙ
|
||||
2. snap = term.snapshot_ansi() // снимок грида в этой же точке
|
||||
3. res { snapshot: snap, cols, rows, cursor }
|
||||
→ rx далее получает ТОЛЬКО output, эмитнутый после snapshot
|
||||
```
|
||||
Актор обрабатывает одно сообщение за раз → между subscribe и snapshot не вклинивается флаш вывода → нет двойной отрисовки и нет дыры. GUI: новый `xterm`-инстанс → пишет `snapshot` → дальше живой поток из канала.
|
||||
|
||||
**detach / разрыв коннекта:** client-task падает → её `broadcast::Receiver` дропается → подписка чистится сама. PTY и грид живут в акторе дальше — демон переживает GUI.
|
||||
|
||||
---
|
||||
|
||||
## 6. Снапшот → ANSI (`spacesh-core`)
|
||||
|
||||
Из alacritty `Term` обходим видимую сетку построчно:
|
||||
- Старт дампа: `ESC[2J ESC[H` (очистка + home) — свежий xterm-инстанс встаёт чисто.
|
||||
- Для каждой ячейки эмитим SGR-атрибуты (цвет fg/bg, жирный, курсив, подчёркивание) **только при изменении** от предыдущей ячейки, затем символ.
|
||||
- Конец строки → `\r\n`.
|
||||
- В конце — позиционирование курсора `ESC[{row};{col}H`.
|
||||
- Скроллбэк не дампим (только видимая область — бюджет репейнта <100мс).
|
||||
|
||||
Детерминированно → юнит-тест: feed известных байтов → фиксированный снапшот; проверка курсора и SGR.
|
||||
|
||||
---
|
||||
|
||||
## 7. Lifecycle демона
|
||||
|
||||
- **Ленивый старт:** первый клиент (GUI), не найдя socket, форкает `spaceshd` и ждёт готовности (поллинг connect с таймаутом).
|
||||
- **Single-instance:** лок-файл `~/.spacesh/daemon.lock` (flock) + проверка живости socket. Кейс «socket есть, демон мёртв»: connect фейлится → удаляем stale socket → стартуем.
|
||||
- **launchd:** user-agent `~/Library/LaunchAgents/xyz.spacesh.daemon.plist` с `KeepAlive` (автоперезапуск при краше). `RunAtLoad` (автостарт при логине) — опционально, по умолчанию **выключен** в этом срезе.
|
||||
- **shutdown:** закрыть все surface-акторы (kill child), снять socket + lock, выйти.
|
||||
|
||||
Транспорт изолирован за трейтом (задел на remote, `DOCS/MAIN.md` §4.3), но в срезе только UDS.
|
||||
|
||||
---
|
||||
|
||||
## 8. Обработка ошибок
|
||||
|
||||
- **Протокол:** невалидный кадр/JSON → `res ok:false {code:"BAD_REQUEST"}`, коннект живёт. Неизвестный `surface_id` → `{code:"NOT_FOUND"}`.
|
||||
- **PTY:** спавн упал → `res ok:false {code:"SPAWN_FAILED", msg}`. Процесс умер → `exit{code}` evt; surface остаётся видимым (закрытие — отдельной `close`).
|
||||
- **Изоляция:** сбой одной client-task не роняет демон и другие surfaces (best-effort на каждого клиента).
|
||||
|
||||
---
|
||||
|
||||
## 9. Тесты
|
||||
|
||||
| Уровень | Что проверяем |
|
||||
|---|---|
|
||||
| `spacesh-proto` (юнит) | round-trip encode/decode каждого Cmd/Evt; кадрирование (частичный/склеенный кадр) |
|
||||
| `spacesh-core` (юнит) | feed байтов → детерминированный снапшот; корректность курсора и SGR |
|
||||
| `spacesh-pty` (интеграц) | spawn `echo hello` → читаем вывод; `resize` не падает |
|
||||
| `spaceshd` (интеграц) | temp-socket: `open`→`new_surface`(`printf`)→`attach`→снапшот с выводом; reattach после дропа коннекта возвращает тот же экран; `close`→`exit` evt |
|
||||
| `app` (ручной) | байты летают GUI↔демон↔PTY; kill GUI → reopen → экран восстановлен из снапшота |
|
||||
|
||||
---
|
||||
|
||||
## 10. Бюджеты производительности (из `DOCS/MAIN.md` §2)
|
||||
|
||||
- Keypress → echo: < 16 мс при нормальной нагрузке.
|
||||
- Батчинг `output`: коалесцировать ~4–8 мс **или** до ~16 КБ, что раньше; не эмитить по байту.
|
||||
- Backpressure: ограниченный канал на подписчика; грид авторитетный, не растём в памяти.
|
||||
- Репейнт при reattach: снапшот < 100 мс на типовом гриде.
|
||||
|
||||
---
|
||||
|
||||
## 11. Порядок реализации (внутри среза)
|
||||
|
||||
Снизу вверх, вертикальный срез сначала:
|
||||
1. `spacesh-proto` — типы + кадрирование + тесты.
|
||||
2. `spacesh-pty` — spawn/reader/батчинг/resize + тест.
|
||||
3. `spaceshd` (M0-ядро) — socket-сервер, registry, surface-actor, `open`/`new_surface`/`input`/`output`/`close`/`status`/`shutdown`.
|
||||
4. `app` минимальный — src-tauri мост (схема B) + один `TerminalView` + `SurfaceList`. Байты летают (конец M0).
|
||||
5. `spacesh-core` — грид (alacritty) + снапшот→ANSI + тесты.
|
||||
6. `spaceshd` (M1) — `term.feed` в акторе, `attach`/`detach` со снапшотом, `broadcast`-fan-out.
|
||||
7. `app` (M1) — репейнт из снапшота на attach, reattach-флоу.
|
||||
8. launchd-обвязка + ленивый старт + single-instance.
|
||||
|
||||
Конец среза: убил GUI — агент жив в демоне; открыл — экран восстановился.
|
||||
Reference in New Issue
Block a user