# 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` на 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, 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 — агент жив в демоне; открыл — экран восстановился.