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