Files
spaceshell/DOCS/MAIN.md
T
2026-06-09 19:50:15 +07:00

34 KiB
Raw Blame History

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. Конверт сообщения

// Запрос (клиент → демон)
{ "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):

[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.