From fce559f24c11047b06017c5d52223802d8c1f2a0 Mon Sep 17 00:00:00 2001 From: kirich1409 Date: Sat, 18 Apr 2026 11:29:24 +0300 Subject: [PATCH 01/10] Add Dialogue feature specification + deferred-specs policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commits the authoritative specification for the Dialogue feature (EPIC #250) to docs/architecture/ as durable documentation — the research worktree can be removed, while the spec continues to live on main and guides implementation whenever it is picked up. Adds two new architecture documents: - dialogue.md — feature spec: problem, user stories, architecture, session surface model, UX live-feel patterns, external dependency decisions, risk register, MVP acceptance criteria, out-of-MVP list. - dialogue-events.md — Claude Code stream-json event catalog with wire-level detail, UI mapping, dedup rules, edge cases. Parser contract for D-8 / D-10. Amends CLAUDE.md Documentation Maintenance section with a new "Deferred feature specs" subsection: specifies that any feature decomposed into an epic but deferred from immediate implementation MUST commit its full spec to docs/architecture/. Research artefacts in swarm-report/ are ephemeral; docs/architecture/ is durable. Updates Architecture Documents index to include the two new files. Closes #254 --- CLAUDE.md | 14 + docs/architecture/dialogue-events.md | 425 +++++++++++++++++++++++++++ docs/architecture/dialogue.md | 395 +++++++++++++++++++++++++ 3 files changed, 834 insertions(+) create mode 100644 docs/architecture/dialogue-events.md create mode 100644 docs/architecture/dialogue.md diff --git a/CLAUDE.md b/CLAUDE.md index f076827..c543514 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -328,6 +328,8 @@ When starting a task: - `docs/architecture/relay-cloud-architecture.md` — full architecture (23 sections, reviewed 5+ rounds) - `docs/architecture/relay-cloud-decomposition.md` — task decomposition (30 tasks, 5 waves) +- `docs/architecture/dialogue.md` — Dialogue feature spec (structured chat UI for agent sessions; EPIC #250) +- `docs/architecture/dialogue-events.md` — Claude Code stream-json event catalog (parser contract for Dialogue) - `docs/testplans/` — 114 test cases across 14 files (see `docs/testplans/00-strategy.md`) - `runner/README.md` — runner setup guide @@ -401,6 +403,18 @@ Documentation lives in `docs/` and component-level READMEs (e.g., `runner/README - Docs describe what IS (based on code), not what was planned - When closing an issue or merging a feature PR, verify affected docs are current +### Deferred feature specs + +When a feature is decomposed into a GitHub epic + child issues but implementation is **deferred** (will happen later, not immediately), the complete specification MUST be committed to `docs/architecture/.md` (plus auxiliary files like `-events.md` for wire protocols, etc.). Research artefacts in `swarm-report/` are ephemeral and should not be relied on as a source of truth — they live in development worktrees and disappear with them. + +Rules for deferred specs: + +- **One spec file per feature** in `docs/architecture/` — named `.md`. Auxiliary spec files share the slug prefix. +- **Epic references the doc**, doc references the epic — both ways linked (`docs/architecture/dialogue.md` cites `EPIC #250`, epic body cites the doc path). +- **Doc is treated as living** — updated in the **same PR** as any child-issue PR that deviates from or clarifies the spec. Docs describe what IS, not what was planned — once implementation starts, drift is fixed as it appears, not batched. +- **Epic and docs are the durable artefact** after the worktree is cleaned up. The research report in `swarm-report/` may be removed along with its worktree at any time; everything that must persist already lives on main. +- **Cleanup after decomposition** — when the decomposition PR merges and no implementation is planned in the immediate session, remove the research worktree (`git worktree remove .worktree/`). The branch can stay for history or be deleted; the spec on main is canonical. + ### Quality gate Before merging, verify: - If the change adds/modifies a feature → corresponding docs updated diff --git a/docs/architecture/dialogue-events.md b/docs/architecture/dialogue-events.md new file mode 100644 index 0000000..00f9660 --- /dev/null +++ b/docs/architecture/dialogue-events.md @@ -0,0 +1,425 @@ +# Dialogue event catalog — Claude Code stream-json + +**Status:** Wire-level parser contract for the Dialogue feature. Companion to [`dialogue.md`](dialogue.md). + +**Scope:** every event type emitted by `claude -p --output-format stream-json --verbose --include-partial-messages` — schema, UI mapping, dedup rules, and edge cases. This is the authoritative reference for `ClaudeStreamJSONParser` (D-8) and `AgentChatFeature` (D-10). + +**Related documents:** +- [`dialogue.md`](dialogue.md) — feature specification and architecture. +- [EPIC #250](https://github.com/androidbroadcast/Relay/issues/250) — task tracking. + +**External references:** +- Anthropic headless docs — https://code.claude.com/docs/en/headless +- Streaming output — https://code.claude.com/docs/en/agent-sdk/streaming-output +- CLI reference — https://code.claude.com/docs/en/cli-reference +- Tools reference — https://code.claude.com/docs/en/tools-reference +- Raw API streaming — https://platform.claude.com/docs/en/build-with-claude/streaming +- Python SDK types (canonical schema): `anthropics/claude-code-sdk-python/src/claude_agent_sdk/types.py` +- Reference implementations: `getAsterisk/opcode`, `stravu/crystal`, `zed-industries/claude-code-acp` + +Initial specification date: 2026-04-18. + +--- + +## 1. Транспорт + +- Процесс `claude` пишет **newline-delimited JSON** в stdout. Каждая строка — один завершённый JSON-объект, разделитель `\n`. +- stderr используется для диагностики, не для protocol payload. +- Relay буферизует байты до `\n`, декодирует построчно. Лимит строки — **10 MiB** (tool_result для Bash может быть жирным). +- Все сообщения имеют корневые поля: + ```ts + { + type: "system" | "assistant" | "user" | "result" | "stream_event", + session_id: string, // UUID, стабильный в рамках сессии + uuid: string | null, // уникальный id события (обязателен в stream_event / system) + parent_tool_use_id: string | null, // не null у events от subagent (Task tool) + ... + } + ``` + +## 2. Каталог событий + +### 2.1. `type: "system"` — субтипы + +#### `system/init` + +Первый ивент сессии. Содержит метаданные: `session_id`, `cwd`, `model`, `permissionMode`, +`tools[]`, `mcp_servers[]` (с `status`: connected/failed/needs-auth/pending/disabled), +`plugins[]`, `plugin_errors[]?`, `slash_commands[]?`, `agents[]?`, `output_style?`, +`apiKeySource` (user/project/env/none). + +**`permissionMode`** ∈ `default | acceptEdits | plan | bypassPermissions | dontAsk | auto`. + +#### `system/api_retry` + +Перед авто-retry при retryable API error. Поля: `attempt`, `max_retries`, `retry_delay_ms`, +`error_status` (HTTP code or null), `error` ∈ `{authentication_failed, billing_error, rate_limit, +invalid_request, server_error, max_output_tokens, unknown}`. + +#### `system/plugin_install` + +Только при `CLAUDE_CODE_SYNC_PLUGIN_INSTALL=1`. Эмитится до `init`. Поля: `status` ∈ +`started | installed | failed | completed`, `name?`, `error?`. + +#### `system/compact_boundary` + +История компактирована (авто при `autoCompactThreshold` или ручной `/compact`). Поля: +`compact_metadata: { trigger: "manual"|"auto", pre_tokens: number }`. + +#### `system/rate_limit` (observed в Python SDK как `RateLimitEvent`) + +Поля: `rate_limit_info: { status: "allowed"|"allowed_warning"|"rejected", resets_at, rate_limit_type, +utilization, overage_status?, overage_resets_at?, overage_disabled_reason? }`. + +#### `system/` — fallback + +Silent log в raw-panel, UI игнорирует, метрика `relay.events.unknown` инкрементируется. В частности +сюда попадают: `task_started`, `task_progress`, `task_notification` (из background tasks / Cron* — +в MVP не используем). + +### 2.2. `type: "assistant"` + +Полное (накопленное) сообщение ассистента за один LLM-вызов. Эмитится **после** всей цепочки +`stream_event` для того же `message.id`. + +```json +{ + "type": "assistant", + "session_id": "...", + "uuid": "...", + "parent_tool_use_id": null, + "message": { + "id": "msg_01...", + "role": "assistant", + "model": "claude-opus-4-7", + "stop_reason": "end_turn" | "tool_use" | "max_tokens" | "stop_sequence" | "pause_turn" | "refusal", + "content": [ + { "type": "text", "text": "..." }, + { "type": "thinking", "thinking": "...", "signature": "..." }, + { "type": "tool_use", "id": "toolu_01...", "name": "Read", "input": { ... } } + ], + "usage": { "input_tokens", "output_tokens", "cache_creation_input_tokens", "cache_read_input_tokens", "server_tool_use?" }, + "error": null | "authentication_failed" | ... // not-null — ассистент не вернул содержимого + } +} +``` + +**Breaking change 2025:** нужна nested `message` форма (forward-совместимость). Парсить +`message.content`, не корневой `content`. + +### 2.3. `type: "user"` + +**Кейс А — tool_result** (после tool_use): +```json +{ + "type": "user", + "message": { + "role": "user", + "content": [{ + "type": "tool_result", + "tool_use_id": "toolu_01...", + "content": "..." | [{ "type":"text",... }, { "type":"image",... }], + "is_error": false + }] + }, + "tool_use_result": { "stdout":"...", "stderr":"...", "interrupted":false, "isImage":false, "sandbox":false } +} +``` + +`tool_use_result` — Claude Code-специфичный rich payload для Bash/Read/etc., используется для +специализированных виджетов (§3). + +**Кейс B — echo пользовательского ввода** (только при `--input-format stream-json`): +```json +{ "type": "user", "message": { "role": "user", "content": [{ "type":"text","text":"..." }] } } +``` + +### 2.4. `type: "result"` — ровно один на сессию + +```json +{ + "type": "result", + "subtype": "success" | "error_max_turns" | "error_during_execution" | "error_max_budget_usd" | "error_max_structured_output_retries", + "session_id": "...", + "duration_ms": 47320, + "duration_api_ms": 41233, + "is_error": false, + "num_turns": 7, + "stop_reason": "end_turn", + "total_cost_usd": 0.1847, + "usage": { "input_tokens", "output_tokens", "cache_*", "server_tool_use?" }, + "result": "...финальный текст...", // null если is_error + "structured_output": { ... }, // только при --json-schema + "model_usage": { "claude-opus-4-7": { ... } }, + "permission_denials": [{ "tool_name":"Bash","reason":"..." }], + "errors": ["..."] +} +``` + +### 2.5. `type: "stream_event"` (только при `--include-partial-messages`) + +Обёртка над сырым Anthropic API SSE-ивентом. Ключ — `event.type`: + +**`message_start`** — открывает ассистентское сообщение (id, model, empty content, initial usage). + +**`content_block_start`** — открывает блок контента. `content_block.type` ∈ +`text | thinking | tool_use | server_tool_use | web_search_tool_result`. + +**`content_block_delta`** — инкремент. `delta.type` ∈ +- `text_delta` — `{ text: string }` +- `input_json_delta` — `{ partial_json: string }` (куски JSON для tool_use.input) +- `thinking_delta` — `{ thinking: string }` +- `signature_delta` — `{ signature: string }` +- `citations_delta` — `{ citation: {...} }` + +**`content_block_stop`** — закрытие блока по `index`. + +**`message_delta`** — обновление message-level: `stop_reason`, cumulative `usage.output_tokens`. + +**`message_stop`** — конец сообщения. + +**`ping`** — keepalive, silently ignore. + +**`error`** — редко, CLI обычно transform'ит в `system/api_retry` или финализирует с `result.is_error`. + +### 2.6. Hook events (`--include-hook-events`) + +Hook event names (из Python `HookEvent`): +`PreToolUse | PostToolUse | PostToolUseFailure | UserPromptSubmit | Stop | SubagentStop | +PreCompact | Notification | SubagentStart | PermissionRequest | SessionStart | SessionEnd`. + +**Форма в stream-json не документирована publicly.** MVP **не включает** `--include-hook-events` и +silently skip'ает такие events (см. §5). + +--- + +## 3. Mapping на UI + +| Event | Перехватываем | UI | Throttle / Aggregation | +|---|---|---|---| +| `system/init` | Да | Status chip (model · plugins(N) · cwd basename). Warning icon if `plugin_errors.length > 0` | ровно один | +| `system/api_retry` | Да | Inline banner над последним turn: `Retrying… (2/5 · rate_limit · 4s)`. 3+ attempt — красный | Заменяется при новом retry | +| `system/plugin_install` (started) | Нет | Hide startup toast | — | +| `system/plugin_install` (installed) | Да | Toast `Installed plugin {name}`, 3s | — | +| `system/plugin_install` (failed) | Да | Persistent banner на startup screen | — | +| `system/plugin_install` (completed) | Да | Hide installation overlay | — | +| `system/compact_boundary` | Да | Divider в transcript `── History compacted ({trigger} · {pre_tokens}) ──`. Сообщения до divider — opacity 0.7 | — | +| `system/rate_limit` (allowed_warning) | Да | Yellow chip в status bar: `Rate 87% · resets 2h` | Обновляется на каждом | +| `system/rate_limit` (rejected) | Да | Red blocking banner + disable input | — | +| `system/` | Silent | Log в raw-panel | — | +| `stream_event/message_start` | Internal | Создать pending assistant message (id, model, empty content, shimmer) | — | +| `stream_event/content_block_start` (text) | Да | Добавить TextSegment, hide shimmer | — | +| `stream_event/content_block_delta` (text_delta) | Да | Append к TextSegment, markdown re-render | **Throttle 30fps** | +| `stream_event/content_block_start` (tool_use) | Да | ToolCallCard (status=pending, spinner, tool name) | — | +| `stream_event/content_block_delta` (input_json_delta) | Buffer | Накопить в буфере блока по index; не partial-parse | — | +| `stream_event/content_block_stop` (tool_use) | Да | `JSON.parse` буфера → specialized widget (§4); fail → raw input + warning | — | +| `stream_event/content_block_start` (thinking) | Да | ThinkingSegment, collapsed by default (`Thinking…` + spinner) | — | +| `stream_event/content_block_delta` (thinking_delta) | Accumulate | Накопить текст, **не** re-render в collapsed state | — | +| `stream_event/content_block_delta` (signature_delta) | Silent | Сохранить signature на блоке | — | +| `stream_event/content_block_delta` (citations_delta) | Да | Footnote-marker в TextSegment | Buffer до block_stop | +| `stream_event/content_block_start` (server_tool_use) | Да | Как tool_use с badge `server` | — | +| `stream_event/content_block_start` (web_search_tool_result) | Да | Auto-match по tool_use_id → WebSearchWidget с результатами | — | +| `stream_event/content_block_stop` (text) | Да | Финализировать segment, hide typing cursor | — | +| `stream_event/content_block_stop` (thinking) | Да | Spinner → lightbulb icon, финальная длина в header | — | +| `stream_event/message_delta` | Да | Сохранить stop_reason, обновить usage.output_tokens в footer | — | +| `stream_event/message_stop` | Internal | Close pending message, ждать `assistant` | — | +| `assistant` (complete) | Да | Финализировать pending message; `message.content` — authoritative. `message.error` not-null → red border + error | Dedup по `message.id` | +| `user` (tool_result) | Да | Обновить ToolCallCard: status=completed/failed, preview content, сохранить `tool_use_result` | Match O(1) по tool_use_id | +| `user` (echo text) | Да | User bubble на правой стороне | — | +| `result` (success) | Да | Final status chip: `duration · $cost · N turns · tokens`. Разблокировать input | — | +| `result` (is_error=true) | Да | Red error banner с subtype + errors[]. Input заблокирован до reset | — | + +### Dedup-инвариант + +- `stream_event` — realtime source; `assistant` — authoritative snapshot. +- При reconnect + VT-snapshot replay: дубликаты дедуплицируются по составному ключу + `(session_id, event.index, event.type, uuid)` для stream_event; по `message.id` для assistant; + по `tool_use_id` для user/tool_result; по `uuid` для system. +- LRU buffer дедупа — 2000 последних events, cleared on `message_stop`. + +--- + +## 4. Tool widgets + +Для каждого tool — тип виджета, preview, expanded view, edge cases. + +| Tool | Widget | Preview | Expanded | +|---|---|---|---| +| `Read` | ReadWidget | `path(basename) · {N} lines` | Syntax-highlighted text + line numbers | +| `Grep` | GrepWidget | `"{pattern}" → {N} matches in {M} files` | Grouped by file, line highlights | +| `Glob` | GlobWidget | `"{pattern}" → {N} files` | File list, click→Read | +| `LS` | LSWidget | `{path} · {N} entries` | Table (name/size/mtime/type) | +| `Edit` / `MultiEdit` / `Write` | EditWidget | `path · ±{lines}` | **DiffView inline** (+ зелёным, − красным) | +| `NotebookEdit` | NotebookWidget | `{notebook} · cell {id}` | Cell preview | +| `Bash` | BashWidget | `$ {cmd(trunc 80)}` | Мини-терминал (ANSI), exit code, duration, stdout/stderr tabs | +| `BashOutput` | BashWidget (append) | `{bash_id} +{lines}` | Append к предыдущему по bash_id | +| `KillShell` | Inline status | `Killed {shell_id}` | — | +| `Task` / sub-agent | TaskWidget | `{subagent_type}: {description}` | **Nested AgentChatView** (parent_tool_use_id ≠ null routes here), depth indicator | +| `TodoWrite` | TodoWidget | `{done}/{total} · active: {activeForm}` | Checklist: ☐/◐/☑ | +| `WebFetch` | WebFetchWidget | `{host}{path}` | OG-preview card | +| `WebSearch` | WebSearchWidget | `"{query}" → {N} results` | List of title/url/snippet | +| `SlashCommand` | SlashCommandWidget | `/{name}` | Collapsed output | +| `ExitPlanMode` / `EnterPlanMode` | System event | — | Divider в transcript | +| `Skill` | SlashCommandWidget | `skill:{name}` | Nested output | +| `AskUserQuestion` | AskQuestionWidget | `{question}` | Radio/checkbox inline. **MVP: не эмитится** (bypass permission mode) | +| `mcp__{server}__{tool}` | GenericMCPWidget | `{server}/{tool}` | Two tabs: input JSON (pretty) / output | +| Остальные (Monitor/LSP/TaskCreate/Cron*/SendMessage) | Generic | `{tool_name} · ...` | Raw input/output JSON | + +--- + +## 5. Out-of-MVP + +Осознанно откладываем: + +1. **`--include-hook-events`** — форма не документирована, UI-ценность низкая. Silently skip. +2. **`AskUserQuestion` tool** — требует streaming-input + ControlResponse. MVP использует `bypassPermissions`. +3. **Per-tool permission callback** (`can_use_tool`) — MVP: политика через `--permission-mode`, не через UI sheet per вызов. +4. **Plan Mode as live feature** — dividers показываем, но не отдельный UI с "Accept plan". +5. **Streaming thinking в realtime** — thinking копится, показывается только после expand. +6. **MCP tool discovery progress** — не реактивно; релайт только при следующем `init`. +7. **Slash-command authoring / editing** — только отображение результата. +8. **Attachment upload** (image/files через paste) — prompt text-only. +9. **Subagent live view как отдельная вкладка** — nested внутри TaskWidget, не tab. +10. **Structured output streaming** — доступен только в финальном `result.structured_output`. +11. **Extended thinking с `max_thinking_tokens`** — при явном лимите stream_events не эмитятся (known limitation). +12. **Rate-limit adaptive back-off в клиенте** — только banner, retry делает CLI сам. +13. **Background tasks** (`system/task_*`, `CronCreate`) — silent log. + +--- + +## 6. Edge cases + +### 6.1. CLI crash mid-turn (нет `result`) + +- Last pending assistant message → `partial=true`, полупрозрачная рамка, warning icon. +- Inline banner ниже: `Session ended unexpectedly · {reason}`. Buttons: `Reveal raw log`, `Retry turn`. +- Pending tool cards → `abandoned`, серые, no spinner. +- Input разблокирован — новый prompt продолжает truncated transcript. + +### 6.2. Reconnect → VT-snapshot → дубликаты + +См. Dedup-инвариант в §3. LRU 2000 events. + +### 6.3. Unknown event type + +Never crash: log в raw-panel, UI игнорирует, метрика `relay.events.unknown{type,subtype}`. +Exception — `result` с unknown subtype: показываем generic error banner. + +### 6.4. Partial JSON parse fail + +Буфер до `\n`. Лимит 10 MiB. При overflow → drop буфера + red toast + продолжаем с +следующего `\n`. + +### 6.5. Длинный thinking content + +Collapsed by default. Header: `Thinking · ~{tokens} tokens · {elapsed}s`. Expand → raw +текст в мономонохромной рамке, **без** markdown rendering. `thinking_delta` не throttle +(accumulate only). + +### 6.6. Большой tool_result (>1 MiB) + +Truncate: первые 256 KiB + last 256 KiB + `\n… {N} bytes omitted …\n`. Full в +`~/Library/Caches/Relay/tool-results/{tool_use_id}.txt`. Expanded view → `Open full result` +(NSWorkspace). Bash stdout/stderr — независимо. + +### 6.7. Cost thresholds + +- Warning $1.00 (configurable) → yellow banner + "Pause" button (non-blocking). +- Hard $5.00 → red blocking banner, new prompt требует "I understand" confirmation. +- Preferences в Settings, defaults 1/5. + +### 6.8. User interrupt (⌘.) + +1. **Streaming-input mode**: `SDKControlInterruptRequest` в stdin → CLI graceful stop → `result` + с `subtype=error_during_execution`. +2. **Без streaming-input**: `SIGINT` → graceful shutdown, `result` с `is_error=true`. +3. **Крайний случай**: `SIGTERM` → `SIGKILL` через 2s → §6.1. + +**MVP рекомендация:** streaming-input mode сразу (требует prompt через stdin как stream-json). + +--- + +## 7. Запуск CLI из Relay + +```bash +claude -p \ + --output-format stream-json \ + --verbose \ + --include-partial-messages \ + --input-format stream-json \ # для interrupt через control_request + --permission-mode bypassPermissions \ # MVP без interactive prompts + --bare \ # быстрый старт, без discovery + --model claude-opus-4-7 \ + --session-id {UUID} \ # стабильный ID для resume + --add-dir {worktree path} +``` + +**НЕ включаем в MVP:** `--include-hook-events`, `--json-schema`. + +**Опционально (Settings → Advanced):** `--max-turns N`, `--max-budget-usd N`. + +--- + +## 8. Debug / Raw-transcript панель + +**Affordance:** ⌘⌥R — "Reveal Raw Events". Альтернативно — Debug menu → Show Stream Inspector. + +**UI:** overlay sheet 80% высоты. +- Header: `Stream Inspector · {session_id short}`, buttons `[Copy all] [Save .jsonl] [Close]`. +- List: raw JSONL-строки в порядке прихода, моно-шрифт, JSON syntax-highlight. +- Per line: arrival timestamp, `type/subtype` badge, fold/unfold, `[Copy]`. +- Filter: type multiselect, subtype search, event name search (для stream_event). +- `[Pause capture]` — freeze updates. +- Bottom meta: `total: N · bytes: K · unknown: M · parse_errors: P`. + +**Persistence:** in-memory last 1000 events per session. Settings → Advanced → `Persist raw streams` +→ mirror в `~/Library/Caches/Relay/sessions/{session_id}.jsonl`. + +**Use case:** bug reporting — user нажимает ⌘⌥R, копирует raw-строку, шлёт в issue. Без этого +parsing bug становится чёрным ящиком. + +--- + +## 9. Контрольный список парсера (инварианты) + +1. Все сообщения — JSONL, построчно, `\n`-terminated, max 10 MiB / line. +2. Обязательная первая строка — `system/init` (или `plugin_install` если `CLAUDE_CODE_SYNC_PLUGIN_INSTALL=1`). Отсутствие init в первых 100 events — warning в inspector. +3. `session_id` стабилен на протяжении сессии. Смена mid-stream — treated as new session (разрыв). +4. На каждый `content_block_start` должен прийти `content_block_stop` с тем же `index`. Иначе сегмент `incomplete=true`. +5. На каждый `tool_use` в `assistant.message.content` должен быть `user/tool_result` с матчащим `tool_use_id`. Иначе → abandoned. +6. `result` — ровно один. Дубликат → игнор. Отсутствие → §6.1. +7. `stream_event` для блока `index=N` всегда в порядке start → deltas → stop. Interleaving c блоками других `index` допустим (defensive support). +8. Unknown поле внутри известного `type` — не падать, игнорировать, сохранять в raw. + +--- + +## Приложение A: AgentStreamEvent (internal model) + +Promote всех распарсенных событий в нейтральный тип для `AgentChatFeature`. Семантически +близкий к ACP ToolCall/ContentBlock — чтобы при будущей миграции на ACP reducer и UI не +переписывать, только подменять парсер. + +```swift +public enum AgentStreamEvent: Sendable { + case sessionInit(SessionInitInfo) + case sessionEnd(SessionResult) + case messageStarted(MessageID, role: MessageRole, model: String) + case messageCompleted(MessageID, stopReason: StopReason, usage: TokenUsage) + case textDelta(MessageID, text: String) + case thinkingStarted(MessageID, blockIndex: Int) + case thinkingDelta(MessageID, blockIndex: Int, text: String) + case thinkingCompleted(MessageID, blockIndex: Int) + case toolCallStarted(ToolCallID, name: String, kind: ToolKind, messageID: MessageID) + case toolCallInputReady(ToolCallID, input: JSONValue) + case toolCallCompleted(ToolCallID, output: ToolResult) + case toolCallFailed(ToolCallID, error: String) + case apiRetry(attempt: Int, maxRetries: Int, delayMs: Int, reason: RetryReason) + case compactBoundary(trigger: CompactTrigger, preTokens: Int) + case rateLimitStatus(RateLimitInfo) + case error(AgentError) + case unknownEvent(raw: String) +} +``` + +Эта модель — один из главных артефактов спецификации для implementation. ACP-будущее — +замена `ClaudeStreamJSONParser` на `ACPClient` без изменений в reducer/UI. diff --git a/docs/architecture/dialogue.md b/docs/architecture/dialogue.md new file mode 100644 index 0000000..9befe32 --- /dev/null +++ b/docs/architecture/dialogue.md @@ -0,0 +1,395 @@ +# Dialogue — structured chat UI for agent sessions + +**Status:** Specification. Implementation decomposed into [EPIC #250](https://github.com/androidbroadcast/Relay/issues/250) with 30 child tasks (D-1 … D-30). This document is the authoritative spec; it is updated in-sync with any PR that deviates from or clarifies the design. + +**Feature slug:** `dialogue`. Code identifiers: `SessionSurface.agentDialogue(.claudeCode)`, `DialogueFeature` (TCA reducer), `DialogueView` (SwiftUI), `DialogueMarkdownView` (markdown renderer wrapper). + +**Related documents:** +- [EPIC #250](https://github.com/androidbroadcast/Relay/issues/250) — task tracking and current status. +- [`dialogue-events.md`](dialogue-events.md) — Claude Code stream-json event catalog + UI mapping (wire-level parser contract). + +Initial specification date: 2026-04-18. + +--- + +## Problem summary + +Today every agent session in Relay is a raw PTY rendered via SwiftTerm. For shell sessions this is correct. For AI-coding agents it is a mismatch: the agent produces structured output (messages, tool calls, thinking, plugin invocations, diffs), and rendering it as undifferentiated ANSI text discards meaning, hurts scannability, and prevents UX polish (markdown, syntax-highlighted code, collapsible tool output, approval sheets, live progress). + +**Dialogue** introduces a second presentation surface for agent sessions — a structured chat UI that parses the agent's stream and renders messages, tool calls, and thinking as first-class UI primitives. The raw terminal stays available for sessions that need it (interactive TUI prompts, `/login`, shell-like flows). Selection is at session creation, not a live toggle — the UX compensation for "switch mode" expectation is an **Open as Terminal / Open as Dialogue** action that creates a sibling session via `claude --resume ` (context preserved). + +Visual reference — Claude Remote / Claude Desktop redesign; but this is a re-implementation in native SwiftUI, not a port. First supported agent: Claude Code via `-p --output-format stream-json`. Architecture opens the path to ACP / Codex / other agents post-MVP. + +--- + +## Главные выводы (TL;DR) + +1. **Hot-toggle между terminal и chat mid-session невозможен** — это установили все OSS-референсы (opcode, Crystal, Zed/ACP). Claude в TTY и `claude -p --output-format stream-json` — два разных entrypoint'а, не тумблер. Значит UX — это **выбор типа сессии при создании**, как `.shell` vs `.agentTerminal(claudeCode)` vs `.agentChat(claudeCode)`. +2. **Claude Code stream-json стабилен и документирован.** Флаги: `-p --output-format stream-json --verbose --include-partial-messages --bare`. Схема событий покрывает всё, что нужно: `system/init`, `stream_event` (partial delta), `assistant`, `user` (tool_result), `result`. MCP-инструменты идут под префиксом `mcp____`. Для clean MVP этого достаточно, Node-рантайм/SDK не обязательны. +3. **`--input-format stream-json` недокументирован** (issue #24594). Двусторонняя JSONL-сессия через CLI stdin — реверс-инжиниринг. Для MVP — однонаправленный вывод, ввод через обычный stdin (plain text). Native permission-prompts (`canUseTool` из TS SDK) в CLI недоступны — либо `--permission-mode acceptEdits` + allowed-tools список, либо ACP в будущем. +4. **ACP (Agent Client Protocol) от Zed — реальный де-факто стандарт** для multi-agent host'ов: 24+ реализаций, Claude / Codex / Gemini / Cline / Goose через адаптеры. В v0.11 (март 2026) под активной разработкой. Для Relay — правильный долгосрочный таргет, **но не MVP**: нет Swift SDK, Claude работает через Node-адаптер `@zed-industries/claude-agent-acp`. +5. **Архитектурное решение: dual-consumer на одном PTY.** К `TerminalSession` добавляется `rawStdout: AsyncStream` (сырые байты до VT-парсинга). Chat-слой подписывается на этот поток, прогоняет через JSONL-парсер, выдаёт `AgentStreamEvent`. SwiftTerm в chat-сессии не рендерится, но PTY-инфраструктура (spawn, reconnect, env, lifecycle) переиспользуется полностью. +6. **Два новых пакета:** `AgentChat` (domain + TCA reducer + JSONL parser, без SwiftUI) и `AgentChatUI` (views + markdown). Runner и proto **не меняются в MVP** — вся семантика на клиенте. +7. **Markdown-рендеринг — Textual (gonzalezreal).** MIT, Swift 6, macOS 15+, AttributedString-бэкенд, встроенная syntax-highlight. Автор сам позиционирует её как преемника `swift-markdown-ui`, который теперь в maintenance mode. Альтернатива — `apple/swift-markdown` (только parser) + собственный SwiftUI-рендер, если Textual окажется сырым в 0.x. + +--- + +## Approaches Found + +### A. Spawn Claude с stream-json и парсить на клиенте (MVP fast path) + +- **Описание:** Rust runner не знает о chat — просто запускает `claude -p --output-format stream-json --verbose --include-partial-messages --bare --permission-mode acceptEdits` в PTY с нужным workdir/env. Сырой stdout идёт обычным gRPC-путём в клиент. Клиент форкает байты: SwiftTerm (если surface=.agentTerminal) или `ClaudeStreamJSONParser` (если surface=.agentChat). +- **Trade-offs (+/−):** + - + Нулевые изменения runner/proto. + - + Старт в 1–2 спринта. + - + Переиспользует reconnect/auth/mTLS стек. + - − Нет native permission-prompts через `canUseTool` (только allowed-tools список или `acceptEdits`). + - − `--input-format stream-json` не документирован — двусторонний JSONL в будущем придётся реверсить или ждать стабилизации. + - − Одна имплементация = один агент (Claude). Codex/Aider/Cursor потребуют по отдельному парсеру. +- **Evidence:** Codebase (ClaudeCommandBuilder — единственная точка запуска), Web (opcode/Crystal используют этот же паттерн), Docs (stream-json стабилен). +- **Compatibility:** Полная с текущим стеком. Swift 6 strict concurrency OK, macOS 26 OK. + +### B. ACP (Agent Client Protocol) — долгосрочный таргет + +- **Описание:** Relay говорит на JSON-RPC ACP поверх stdio/WebSocket. Для Claude запускается Node-адаптер `@zed-industries/claude-agent-acp`, который оборачивает Claude Code SDK и эмитит ACP-события. Для Codex — `codex-acp`. Для Gemini — нативный support. +- **Trade-offs:** + - + Один код на N агентов (24+ реализаций уже есть). + - + Нативные permission-prompts (`session/request_permission` → SwiftUI sheet). + - + Diff как first-class content block, не парсинг markdown. + - + Tool call lifecycle (pending/in_progress/completed/failed) — bulit-in. + - − Нет официального Swift SDK (Rust, TS, Python, Kotlin, Java, Go — есть; Swift — нет). + - − ACP под heavy development (v0.11, март 2026) — schema может ломаться. + - − Для Claude требует Node-рантайм на машине пользователя (лишняя зависимость). + - − Объём работ — собственный минимальный ACP-клиент в Swift (~500 LOC) + обёртка для запуска адаптера. +- **Evidence:** Web (Zed blog, agentclientprotocol.com, DeepWiki), Docs (ACP schema подтверждена). +- **Compatibility:** Требует самописный Swift SDK. Не MVP. + +### C. Hybrid PTY + chat one session (отклонён) + +- **Описание:** Одна Claude-сессия, два наблюдателя stdout, пользователь переключается видом на лету. +- **Trade-offs:** `claude` в interactive mode и `claude -p --output-format stream-json` — **разные CLI-entrypoint'ы с разными stdout-контрактами**: interactive рисует TUI (ANSI cursors, colors, prompt подтверждений, readline), `-p` отдаёт только NDJSON. Пересесть из одного в другой требует kill + respawn процесса, т.е. потерю контекста. Это не «toggle вида», это пересоздание сессии. +- **Evidence:** Web-research: opcode, Crystal, Zed/ACP, Claude Desktop — все выбирают один entrypoint при старте. +- **Verdict:** Отклонён архитектурно. Следствие — см. §«UX divergence from user request» ниже. + +### D. Свой Swift-порт Anthropic Messages API (альтернатива A) + +- **Описание:** Вместо spawn'а CLI — прямой HTTP-клиент Anthropic Messages API + свой orchestrator (tool registry, permissions, hooks). +- **Trade-offs:** Огромный объём работ (переизобретение Claude Code SDK), потеря совместимости с CLAUDE.md / hooks / slash-commands / plugins, которые живут в CLI. +- **Verdict:** Отклонён. Community Swift SDK (`fumito-ito/AnthropicSwiftSDK`) имеет bus-factor 1. Если появится official — пересмотреть. + +--- + +## Topology alternatives — где парсить JSONL + +Отдельное архитектурное решение: **кто** преобразует байты из stdout Claude в структурированные `AgentMessage`. Варианты: + +| Топология | Где парсер | Plus | Minus | MVP? | +|---|---|---|---|---| +| **Client-side** (выбрано для MVP) | Swift, пакет `AgentChat` | Нулевые proto-изменения, runner остаётся агностичным, iteration-speed по парсеру независим от runner-релизов | Reconnect из VT-snapshot может оборвать JSON на середине строки; при появлении iOS/Android клиентов придётся портировать парсер на каждый | **Да** | +| **Server-side** (Post-MVP) | Rust, в `runner/src/` рядом с `vt_parser.rs` | Structured transcript как first-class gRPC-ответ → чистый reconnect (replay of `AgentMessage`), multi-client sharing бесплатно, persistence через existing SQLite (WAL), schema evolution локализована в одном месте | Breaking change для `terminal.proto`, задержка MVP; runner связывается с форматами конкретных агентов; blast-radius бага в парсере = падение сервера | Post-MVP миграция, **синхронно с ACP-поддержкой** | +| **Hybrid (runner as ACP-proxy)** | Rust в runner переводит stream-json → ACP-frames, клиент видит только ACP | Единый протокол для всех агентов на стороне клиента, многоклиентная поддержка, чистый reconnect | Максимум работы: runner теперь понимает ACP + stream-json + future Codex JSON, tested surface огромен | Далёкое будущее | + +**Вердикт для MVP — client-side.** Обоснование: минимальный diameter изменений, нулевой breaking-change count в proto, полное переиспользование существующего reconnect-механизма, быстрая итерация по парсеру без релизов runner'а. **Точка переоценки — при появлении второго чат-агента (Codex/Aider/ACP)**, после чего стоимость дубляжа парсеров на клиенте перекроет цену server-side миграции. + +--- + +## UX divergence from user request + +Исходный запрос пользователя содержал формулировку **«режим чата или режим терминала и все это так вести»** — это подразумевает toggle внутри живой сессии. Research установил, что **hot-toggle технически невозможен** (Approach C): `claude` interactive и `claude -p` — разные процессы с разными stdout-контрактами, переключение = kill + respawn с потерей контекста. + +**Что делаем вместо toggle:** + +1. **Явный выбор surface при создании сессии.** На экране «New Claude Session» — picker «Terminal / Chat» с кратким превью, что отличается. Дефолт задаётся в `SettingsStore` (см. MVP Decision §1). +2. **Action «Duplicate as Chat» / «Duplicate as Terminal»** — menu item на вкладке (⌘⇧D), создаёт новую вкладку с тем же workdir, тем же agent kind, но противоположным surface. Старая вкладка живёт дальше, контекст не теряется — просто рядом появляется альтернативная презентация. +3. **Keyboard shortcut для быстрого старта** — ⌘⇧N → «New Chat Session», ⌘⌥N → «New Terminal Session». +4. **Settings hint**: в Settings → Appearance или Agents объясняется, почему toggle отсутствует, одним предложением. + +Этот раздел должен быть explicit'но озвучен на экране создания + в release notes первой версии с chat UI — чтобы не собирать тикеты поддержки «где кнопка». + +--- + +## Library / Dependency Recommendations + +| Библиотека | Версия | macOS | Swift 6 | Лицензия | Назначение | Вердикт | +|---|---|---|---|---|---|---| +| **apple/swift-markdown** | snapshot (активный, обновлён ежедневно) | — | ✅ | Apache-2.0 | Parser-only (cmark-gfm + AST) | **Primary**: AST-provider. Гибридный рендер — свой SwiftUI для блоков + Apple `AttributedString(markdown:)` для inline внутри параграфов. 0 bus factor (Apple), полный контроль. MVP ~8 дней, full ~19 дней. | +| **LiYanan2004/MarkdownView** | актуальная | — | ✅ | MIT | Markdown через swift-markdown + Highlightr | Reference для API-дизайна (24 Source-файла на swift-markdown). Adopt'ить целиком не стоит: bus factor 1, Highlightr тянет WebKit, нет встроенного streaming fast path. Изучаем код как шаблон. | +| **Textual** (gonzalezreal/textual) | 0.3.1 | 15+ | ⚠️ непроверено | MIT | Markdown + Prism.js подсветка в одном пакете | **Отклонено для MVP.** Критичные OPEN issues блокируют chat-use case: #47 full re-parse per update → несовместимо со streaming (репортер: «I'm not able to use this package»); #26 infinite layout loop на macOS в chat-use-case; #23 crash на ~200 блоков. 3 месяца без коммитов. Bus factor = 1. Пересмотреть при выходе 1.0 или после закрытия #23/#26/#47. | +| **HighlightSwift** (appstefan) | 1.1.0 | 13+ | ✅ | MIT | Syntax highlighting через JavaScriptCore | Fallback для code-fence, если Textual не покроет все языки. | +| **swift-markdown-ui** (gonzalezreal) | 2.4.1 | 12+ | ⚠️ частично | MIT | **Maintenance mode** — не брать для нового кода | Отклонено. | +| **Down** (johnxnguyen) | 0.11.0 | 10.11+ | ❓ | MIT | CommonMark only, без GFM | Отклонено (заморожен 2023). | +| **Splash** (JohnSundell) | 0.16.0 | — | ❌ | MIT | Syntax highlight **только Swift** | Отклонено (monolingual). | +| **mattt/EventSource** | 1.4.1 | — | ✅ | MIT | SSE для future Messages API path | На полку до пост-MVP. | + +**Что НЕ нужно как сторонняя dep:** +- **JSONL parser** — Foundation `AsyncLineSequence` + `JSONDecoder` покрывают `stream-json`. Никаких сторонних пакетов. +- **Apple Foundation Models** — WWDC25 даёт паттерн streaming snapshots (`PartiallyGenerated`), но это не про Claude Code. Концепция полезна как reference для progressive rendering. + +**Нет в экосистеме (блокеры для альтернатив):** +- Official Anthropic Swift SDK — отсутствует. +- ACP Swift SDK — отсутствует (есть Rust/TS/Python/Kotlin/Java/Go). + +--- + +## Архитектура (итоговый дизайн) + +### Новые пакеты + +| Пакет | Содержимое | Зависимости | +|---|---|---| +| **`AgentChat`** | Domain: `AgentMessage`, `ToolCall`, `ToolResult`, `ApprovalRequest`, `AgentStreamEvent`, `AgentChatSession` protocol. Parser: `ClaudeStreamJSONParser`. TCA: `AgentChatFeature`. **Без SwiftUI, без gRPC, без SwiftTerm.** | `TerminalAbstraction` (только для `rawStdout` hook), `SharedModels` | +| **`AgentChatUI`** | SwiftUI: `AgentChatView`, `UserMessageView`, `AssistantMessageView`, `ToolCallCardView`, `CodeBlockView`, `ThinkingIndicatorView`, `ApprovalPromptView`, `DiffView`, `ChatInputView`, `ChatMarkdownView` (свой SwiftUI renderer поверх `apple/swift-markdown` AST, либо wrapper вокруг LiYanan2004/MarkdownView если spike пройдёт). | `AgentChat`, `DesignSystem`, **apple/swift-markdown** (primary) или **LiYanan2004/MarkdownView** (после spike) | + +### Изменения существующих пакетов + +- **`TerminalAbstraction`** — добавить `rawStdout: AsyncStream` в `TerminalSession`. Эмитит сырые байты **до** VT-парсинга, позволяет chat-слою потреблять поток параллельно SwiftTerm (или вместо него). +- **`TerminalSwiftTerm`** — реализовать `rawStdout` (fork из PTY-master read loop). +- **`RemoteTerminal`** — реализовать `rawStdout` (эмит из `ServerMessage.stdout_data` handler). +- **`AgentOrchestrator` / `ClaudeCommandBuilder`** — принимать `SessionSurface`, для `.agentChat(.claudeCode)` добавлять флаги `-p --output-format stream-json --verbose --include-partial-messages --bare --permission-mode acceptEdits`. +- **`SharedModels`** — ввести `SessionSurface` enum: `.shell / .agentTerminal(AgentKind) / .agentChat(AgentKind)`. Immutable после создания сессии. +- **`PaneManager`** — `Tab.surface: SessionSurface`. Routing в UI по surface. +- **`AgentSessionFeature.State.presentation: Presentation`** (enum, `.terminal(TerminalFeature.State) | .chat(AgentChatFeature.State)`) со scope через `ifCaseLet`. +- **`SettingsStore`** — ключ `preferredClaudeSurface: SessionSurface = .agentChat(.claudeCode)`. +- **`MainFeature` view** — `switch tab.surface` → `TerminalView` либо `AgentChatView`. +- **`DesignSystem`** — добавить примитивы: `Card`, `StatusIndicator`, `BlockCodeContainer`. Markdown — **не** в DS (ISP: чтобы settings/welcome/sidebar не тянули Textual). + +### Proto/runner в MVP — без изменений + +Runner остаётся агностичным terminal-сервером. JSONL-парсинг — только на клиенте. Это фиксирует breaking-change count = 0, не вводит proto-миграции, сохраняет простоту MVP. + +### Dependency graph (проверено на циклы) + +``` + Relay.app + │ + MainFeature + ┌─────────┼──────────┐ + ▼ ▼ ▼ + TabFeature AgentOrchestrator ServerList… + │ + AgentSessionFeature + │ (Presentation enum) + ┌─────────┴──────────┐ + ▼ ▼ + TerminalFeature AgentChatFeature + │ (AgentChat pkg) + ▼ │ + ┌───────┴────────┐ │ + ▼ ▼ │ +TerminalSwiftTerm RemoteTerminal + │ │ │ + └────┬───────────┘ │ + ▼ │ + TerminalAbstraction ◄────────┘ (rawStdout) + │ + ▼ + SharedModels + ▲ + │ + AgentChatUI ──► AgentChat + │ + ▼ + DesignSystem + │ + ▼ + Textual (transitive — только через AgentChatUI) +``` + +Циклов нет. `TerminalFeature` не знает про chat. `AgentChat` не знает про SwiftUI. `DesignSystem` не знает про markdown/Claude. + +### AgentChatSession — семантический контракт + +```swift +@MainActor +public protocol AgentChatSession: AnyObject, Sendable { + var id: UUID { get } + var kind: AgentKind { get } + var status: AgentChatStatus { get } + var transcript: AsyncStream { get } + func start() async throws + func send(_ input: AgentInput) async throws + func approve(_ request: ApprovalRequest, decision: ApprovalDecision) async throws + func terminate() async +} +``` + +Первая реализация — `ClaudeCodeJSONLSession` поверх `TerminalSession.rawStdout`. Future — `AcpSession`, `CodexJSONLSession`, без изменений в reducer'е и UI. + +--- + +## Risks and Concerns + +| Риск | Severity | Mitigation | +|---|---|---| +| **Reconnect в chat mode ломает transcript** — VT-snapshot не подходит для JSONL, может оборвать JSON на середине | Critical | Парсер robust к partial lines (буферизация до `\n`), дедуп по Claude `message_id`; в пост-MVP — миграция на server-side топологию с первоклассным chat-snapshot RPC | +| **Агент выпадает в interactive TUI-промпт** — `/login`, OAuth flow, MCP server approval, `claude migrate-installer`, first-run onboarding. Chat UI их не видит и не умеет показать | Major | Pre-flight check через `claude doctor` / API-key проверку перед стартом; при обнаружении missing-auth — explicit error screen «Нужна интерактивная настройка» с CTA «Open as terminal». Whitelist известных pre-conditions поддерживается в `ClaudeEnvironment` | +| **Schema drift Claude stream-json** — Anthropic может переименовать поле / добавить event type, chat сломается при апдейте CLI | Major | (a) версионирование схемы в парсере — `Codable` с `decodeIfPresent` + tolerance к unknown keys; (b) graceful degradation на unknown event → лог + сырой JSON в debug-панели вместо краша; (c) CI-тест против реального `claude` в последней версии; (d) snapshot-фикстуры с примерами JSONL | +| **User confusion от отсутствия toggle** — пользователь запросил «switch mode», получает «pick at creation» | Major | См. раздел «UX divergence»: явный picker + «Duplicate as X» action + release notes. Первая версия — opt-in флаг в Settings, не default | +| **Tool-output не помещается в карточку** — `Bash(find /)` выдаёт 500KB, card-view зависает | Major | ToolResultView: truncate на 2KB/40 строк + «Show full» → открывает modal с scroll + virtualization. Отдельный limit для binary/non-UTF8 → показывать summary «N bytes binary» | +| **Permission-prompts в `-p` mode недоступны** — `canUseTool` есть только в TS/Python SDK | Major | MVP: `--permission-mode acceptEdits` + `--allowedTools` список из `SettingsStore`. User warning: «chat mode не может запросить подтверждение инструментов» | +| **Markdown renderer стабильность** — если идём через LiYanan2004 fork либо 3rd-party пакет | Major | Обернуть в `ChatMarkdownView` фасад в `AgentChatUI`. Если идём через apple/swift-markdown + custom renderer — риск исчезает, но добавляется 2–4 недели work | +| **Performance при высоком throughput** — partial deltas 30/sec × большой transcript | Major | Throttle 30fps parser→reducer, профайлинг после MVP, эскалация в `performance-expert` | +| **State drift между mode при ошибке парсера** | Warning | surface immutable; на parser error — error banner + «Restart as Terminal» (kill+new tab) | +| **`--input-format stream-json` недокументирован** — если позже понадобится двусторонний JSONL | Warning | Для MVP input — plain stdin (обычный текст). Следить за issue #24594 | +| **SwiftTerm не умеет suspend render на macOS** — hide vs tear-down | Warning | Chat-сессия: SwiftTerm view не монтируется вообще. При смене surface — kill+restart, не переключение. | +| **TCA reducer bloat в `AgentSessionFeature`** | Minor | Если >500 LOC — разнести lifecycle и presentation на helpers | +| **i18n всех chat strings** | Minor | Сразу через `Localizable.xcstrings`, даже для MVP EN-only | + +--- + +## Recommendation + +**Рекомендация — Approach A для MVP: spawn `claude -p --output-format stream-json` и клиентский JSONL-парсинг.** Архитектура по варианту «dual-consumer поверх PTY» с `rawStdout` hook — минимальная инвазия, нулевые proto-изменения, переиспользование всей существующей PTY-инфраструктуры. + +**Ключевые моменты:** + +1. Ввести `SessionSurface` enum — выбор режима фиксируется при создании, не переключается. +2. Добавить `rawStdout: AsyncStream` в `TerminalSession`, реализовать в обоих адаптерах. +3. Создать пакеты `AgentChat` (domain+TCA) и `AgentChatUI` (SwiftUI+Textual). +4. Markdown — **apple/swift-markdown (AST) + собственный SwiftUI renderer** в `AgentChatUI`, ИЛИ `LiYanan2004/MarkdownView` после 1–2 дня spike на compatibility. Textual **отклонён** (см. Dependencies table — 3 критичных OPEN issues блокируют streaming use case). JSONL парсить Foundation'ом без сторонних deps. +5. `AgentChatSession` — семантический protocol, готовый к ACP/Codex в будущем. +6. Proto/runner не трогать в MVP. + +### Cross-surface session resume (new) + +Claude Code хранит session-state в `~/.claude/projects//.jsonl` **независимо от output-format**. Это значит: + +- Сессия, запущенная в `.agentChat(.claudeCode)` (stream-json), может быть **возобновлена** через `claude --resume ` в `.agentTerminal(.claudeCode)` (interactive TUI) — и наоборот. Контекст (история, cwd) переживает смену presentation. +- UX — **«Open as Terminal» / «Open as Chat»** action (⌘⇧D / ⌘⇧T) создаёт **новую вкладку** с тем же `session_id`, но противоположным surface. Это — ответ на пользовательское ожидание «switch mode» без нарушения архитектурного запрета hot-toggle: физически всё равно kill + respawn с `--resume`, но контекст не теряется. +- MVP spike: проверить работоспособность `--resume` в обе стороны на текущих версиях Claude Code, зафиксировать список поддерживаемых версий. + +### MVP Acceptance Criteria + +Минимум, чтобы «MVP chat mode» считался done: + +1. **Создание `.agentChat(.claudeCode)` сессии** из UI (picker в New Session flow) запускает `claude -p --output-format stream-json --verbose --include-partial-messages --bare --permission-mode acceptEdits` в корректном workdir, unicorn'ит JSONL в `AgentChatFeature.State.transcript`. +2. **Progressive markdown rendering** assistant-сообщений через Textual с throttle 30fps — без фликера на типовом ответе 2000 токенов. +3. **Tool calls отображаются карточками**: Read / Edit / Bash / Grep / WebFetch / `mcp__*` — с collapse/expand и truncate output >2KB. +4. **Ошибки парсера не крашат UI** — unknown event → лог + отображение в debug-панели, сессия продолжается. +5. **Graceful terminate** — kill chat-сессии корректно завершает Claude-процесс, закрывает AsyncStream'ы, освобождает ресурсы (без зомби-процессов). +6. **Pre-flight check** — при missing API key / выпавшем Claude в `/login` flow показывает explicit error screen «Needs interactive setup. Open as Terminal». +7. **«Open as Terminal» action** с keyboard shortcut ⌘⇧D — создаёт sibling tab с **тем же session_id** (через `claude --resume`) в terminal surface. Обратный action «Open as Chat» на terminal-сессии. Контекст сохраняется. +8. **a11y checklist** — все 9 пунктов из CLAUDE.md DS policy покрыты для `AgentChatView`, `MessageBubble`, `ToolCallCardView`, `ApprovalPromptView`. + +### Prerequisites (до старта implement-стадии) + +- **DesignSystem components**: `Card`, `StatusIndicator`, `BlockCodeContainer` должны быть смерджены в DS с `ds-api` label **до** начала работы над `AgentChatUI`, иначе неизбежна дубляж-реализация. +- **Textual dependency approval**: добавление Textual в `Package.swift` требует явного апрува пользователем (CLAUDE.md §Dependencies) — задать вопрос в начале decompose-стадии. + +### Time estimate + +Реалистичная разбивка (один разработчик, Swift 6 + TCA expertise): + +| Блок | Дни | +|---|---| +| `rawStdout` hook в TerminalSession + два адаптера | 3 | +| `ClaudeStreamJSONParser` + fixture-тесты | 4 | +| `AgentChat` TCA reducer + доменные типы | 5 | +| `AgentChatUI` базовые views (bubble, tool card, input) | 7 | +| Markdown renderer — гибрид swift-markdown AST + AttributedString(markdown:) для inline + own SwiftUI для блоков (MVP subset: headings, paragraphs, code block без highlight, lists, blockquote, thematic break, task-list icon) | 8 | +| Streaming стратегия: coalesce/throttle 30fps + block-boundary split + LRU cache `[hash: RenderedBlock]` | включено выше | +| Spike на `--resume` cross-surface (⌘⇧D / ⌘⇧T «Open as Terminal» / «Open as Dialogue») | 1 | +| Events parsing: `ClaudeStreamJSONParser` + fixture тесты (см. [`dialogue-events.md`](dialogue-events.md)) | включено в 4 дня парсера выше | +| `SessionSurface` + routing в MainFeature/PaneManager | 3 | +| DesignSystem primitives (prerequisite) | 3 | +| Docs + tests + a11y sweep | 3 | +| **Итого** | **~30 дней чистого времени ≈ 2–3 спринта (4–6 недель календарно)** | + +### Post-MVP + +- **Persistence chat-transcripts** + миграция на **server-side топологию** (runner парсит JSONL, отдаёт через новый gRPC `ChatSnapshot` RPC). +- **ACP-поддержка** как второй реализации `AgentChatSession` — триггер для пересмотра всей топологии. +- **Codex / Aider / Cursor Agent** — через отдельные `*JSONLSession` либо общий ACP-путь. +- **Syntax-highlighted diff view** для Edit/MultiEdit. +- **Native permission-prompts** (через ACP или свой Node-адаптер, не CLI). +- **Default `.agentChat`** — после первой версии с opt-in, если feedback положительный. + +### ACP reassessment trigger + +Когда появляется требование поддержать **второй чат-агент** (Codex / Aider / Cursor / любой не-Claude), пересматриваем топологию: +- Если дельта между парсерами большая (разные schema) → мигрируем на ACP (Approach B) как primary. +- Если агент эмитит похожий JSONL → добавляем `*JSONLSession` рядом с `ClaudeCodeJSONLSession`, переоцениваем ACP ещё через один агент. + +--- + +## MVP Decisions (ранее были Open Questions) + +1. **Дефолтный surface для Claude — `.agentTerminal` в первой версии; opt-in флаг `Experimental: Chat UI for Claude` в Settings.** После позитивного feedback через 1–2 релиза — переключить default на `.agentChat`. Обоснование: chat UI в MVP сырой, terminal — проверенный fallback; пользователи, которые сознательно включат флаг, толерантнее к багам. +2. **Transcript persistence — in-memory в MVP + VT-snapshot replay при reconnect.** Post-MVP — server-side SQLite (синхронно с proto-миграцией). SwiftData локально — **не** делаем: размывает single source of truth. +3. **Fallback при ошибке старта stream-json — explicit error screen, не автоматический.** CTA «Open as Terminal instead» создаёт sibling tab. Обоснование: пользователь выбрал chat, тихое переключение нарушит expectation. +4. **Streaming throttle — 30fps** по умолчанию (согласуется с Textual style). Настраиваемое через `defaults write` hidden key; UI-setting — только если будут жалобы. + +## Остающиеся Open Questions (требуют user/UX-review) + +1. **Tool-approval UX при `acceptEdits`** — показывать ли retrospective «было вызвано: Bash `npm install`» с возможностью cancel mid-execution? Или полностью доверять allowlist? Нужен UX-review — не блокирует decompose, но повлияет на дизайн `ToolCallCardView`. +2. **Shared chat context между вкладками** — если две Claude-сессии в одной worktree, делить ли память/контекст? (Claude Code Desktop имеет side-chat — смежный thread в main-сессии.) **Post-MVP**, не блокирует. + +--- + +## Suggested Next Actions + +| Situation | Action | +|---|---| +| **MVP ready to decompose** — MVP Decisions зафиксированы, остающиеся Open Questions не блокируют | `/decompose-feature` с этим research как input | +| **Prerequisite**: перед AgentChatUI нужно смёржить DS primitives (`Card`, `StatusIndicator`, `BlockCodeContainer`) | Первая задача в декомпозиции — **Wave 0: DS primitives** с `ds-api` label | +| **Textual dependency approval** — новая SPM-зависимость требует явного согласия пользователя | Поднять вопрос в начале decompose-стадии | +| Tool-approval UX (remaining Open Question #1) | UX-review через `ux-expert` параллельно с decompose, не блокирует Wave 0–1 | +| Validation плана перед стартом | `/plan-review` после decompose | +| Нужен live mockup / proof-of-concept JSONL-парсинга | Опциональный spike — 1–2 дня на `ClaudeStreamJSONParser` + vertical-slice UI | + +**Рекомендованный next step:** `/decompose-feature` с этим research как input. Research доведён до состояния, при котором декомпозиция получит конкретные границы задач и acceptance criteria. Dependency-approval для Textual поднять сразу в начале. + +--- + +## Sources + +### Claude Code / stream-json +- https://code.claude.com/docs/en/headless +- https://code.claude.com/docs/en/agent-sdk/streaming-output +- https://code.claude.com/docs/en/agent-sdk/typescript-v2-preview +- https://platform.claude.com/docs/en/agent-sdk/permissions +- https://github.com/anthropics/claude-code/issues/24594 (`--input-format stream-json` undocumented) +- https://github.com/anthropics/claude-agent-sdk-typescript + +### ACP +- https://agentclientprotocol.com/get-started/introduction +- https://agentclientprotocol.com/protocol/schema +- https://github.com/agentclientprotocol/agent-client-protocol +- https://github.com/zed-industries/claude-agent-acp +- https://zed.dev/docs/ai/external-agents +- https://zed.dev/blog/claude-code-via-acp + +### OSS GUI references +- https://github.com/getAsterisk/opcode +- https://github.com/stravu/crystal +- https://github.com/andrepimenta/claude-code-chat +- https://github.com/PallavAg/Apple-Intelligence-Chat + +### Claude Remote / Desktop +- https://claude.com/blog/claude-code-desktop-redesign +- https://code.claude.com/docs/en/remote-control + +### SwiftUI markdown / streaming +- https://github.com/apple/swift-markdown (**primary** — AST provider) +- https://github.com/LiYanan2004/MarkdownView (reference code, not adopted) +- https://github.com/gonzalezreal/textual (rejected — critical open issues block chat streaming) +- https://github.com/gonzalezreal/swift-markdown-ui (maintenance mode) +- https://github.com/appstefan/highlightswift (v1.1+ candidate for syntax highlighting) +- https://github.com/JohnSundell/Splash (v1.1+ alternative for syntax highlighting) +- https://medium.com/safe-engineering/from-stream-to-screen-handling-genai-rich-responses-in-swiftui-da138acfaa05 +- https://developer.apple.com/videos/play/wwdc2025/286/ (Foundation Models streaming snapshots) + +### Swift ecosystem +- https://github.com/mattt/EventSource (SSE) +- https://github.com/fumito-ito/AnthropicSwiftSDK (community, bus-factor 1) +- https://github.com/grpc/grpc-swift +- https://github.com/migueldeicaza/SwiftTerm + +### Relay codebase (file locations verified by Explore) +- `MacApp/Packages/TerminalAbstraction/Sources/TerminalAbstraction/TerminalSession.swift` +- `MacApp/Packages/AgentOrchestrator/Sources/AgentOrchestrator/ClaudeCommandBuilder.swift` +- `MacApp/Packages/AgentOrchestrator/Sources/AgentOrchestrator/AgentSessionFeature.swift` +- `MacApp/Packages/PaneManager/Sources/PaneManager/TabFeature.swift` +- `MacApp/Packages/TerminalFeature/Sources/TerminalFeature/TerminalFeature.swift` +- `MacApp/Packages/DesignSystem/` (токены + components) +- `proto/terminal.proto` (не меняется) From fbcffd03a0de5be62a23806789fb33112d92c6eb Mon Sep 17 00:00:00 2001 From: kirich1409 Date: Sat, 18 Apr 2026 11:32:57 +0300 Subject: [PATCH 02/10] Add status banner to deferred specs + lifecycle rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deferred specs are snapshots in time — the codebase moves while the spec sleeps, and picking up an epic months later risks implementing against a stale design. A mandatory status banner makes the drift risk visible at the top of every deferred doc and forces re-verifi- cation on the first implementation PR. Changes: - dialogue.md + dialogue-events.md — add the mandatory status banner with snapshot date, link to EPIC #250, and the re-verify obligation. - CLAUDE.md Deferred-specs subsection — codifies: - required banner format (copy-paste template), - re-verify step on the first child-task PR, - status transitions (Deferred → Implemented → Archived), - retirement policy for abandoned specs. --- CLAUDE.md | 32 ++++++++++++++++++++++++---- docs/architecture/dialogue-events.md | 4 +++- docs/architecture/dialogue.md | 8 ++++--- 3 files changed, 36 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c543514..a3eefa5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -407,13 +407,37 @@ Documentation lives in `docs/` and component-level READMEs (e.g., `runner/README When a feature is decomposed into a GitHub epic + child issues but implementation is **deferred** (will happen later, not immediately), the complete specification MUST be committed to `docs/architecture/.md` (plus auxiliary files like `-events.md` for wire protocols, etc.). Research artefacts in `swarm-report/` are ephemeral and should not be relied on as a source of truth — they live in development worktrees and disappear with them. -Rules for deferred specs: +A deferred spec is a **snapshot in time**. The codebase continues to evolve while the spec sleeps; when implementation finally starts, the spec may drift from `main`. The rules below protect against picking up stale designs. + +#### Mandatory status banner + +Every deferred spec file starts with a prominent banner at the very top (before any other content): + +```markdown +> ⚠️ **Status: Deferred — specification only, not yet implemented.** +> +> Snapshot as of **YYYY-MM-DD**. Implementation tracked in [EPIC #N](link). +> Until work starts, this document may drift from the current codebase. +> +> **Before picking up the first child task:** re-verify this spec against +> current `main`. Update affected sections in the same PR that begins +> implementation. Once the MVP lands, remove this banner and replace with +> `Status: Implemented` + a "Last updated" date. +``` + +The banner communicates three things: (1) the doc is aspirational, not yet shipped; (2) the date of the snapshot, so drift is measurable; (3) the re-verification obligation on the first implementation PR. + +#### Lifecycle rules -- **One spec file per feature** in `docs/architecture/` — named `.md`. Auxiliary spec files share the slug prefix. +- **One spec file per feature** in `docs/architecture/` — named `.md`. Auxiliary spec files share the slug prefix (e.g., `dialogue-events.md`). - **Epic references the doc**, doc references the epic — both ways linked (`docs/architecture/dialogue.md` cites `EPIC #250`, epic body cites the doc path). -- **Doc is treated as living** — updated in the **same PR** as any child-issue PR that deviates from or clarifies the spec. Docs describe what IS, not what was planned — once implementation starts, drift is fixed as it appears, not batched. -- **Epic and docs are the durable artefact** after the worktree is cleaned up. The research report in `swarm-report/` may be removed along with its worktree at any time; everything that must persist already lives on main. +- **Status banner is mandatory** on any deferred spec — see format above. Not optional. +- **Re-verify before implementation** — the first child-task PR includes an audit: check that modules, APIs, dependencies, and risks cited in the spec still match `main`. Any drift is fixed in the same PR, not deferred further. +- **Spec is living after implementation begins** — updated in the same PR as any child-issue PR that deviates from or clarifies the design. Docs describe what IS, not what was planned. +- **Status transitions** — when the MVP acceptance criteria are met, replace the `Deferred` banner with `Status: Implemented` + `Last updated: YYYY-MM-DD`. When the feature ships a new major iteration, update the "Last updated" date. +- **Epic + docs are the durable artefact** after the worktree is cleaned up. The research report in `swarm-report/` may be removed along with its worktree at any time; everything that must persist already lives on main. - **Cleanup after decomposition** — when the decomposition PR merges and no implementation is planned in the immediate session, remove the research worktree (`git worktree remove .worktree/`). The branch can stay for history or be deleted; the spec on main is canonical. +- **Retirement** — if a deferred spec is abandoned (product decision to never build, or superseded by a different approach), don't silently delete — replace the banner with `Status: Archived — superseded by ` or `Status: Archived — not pursued`, keep the file for historical context. Remove after one release cycle if no references remain. ### Quality gate Before merging, verify: diff --git a/docs/architecture/dialogue-events.md b/docs/architecture/dialogue-events.md index 00f9660..810b13d 100644 --- a/docs/architecture/dialogue-events.md +++ b/docs/architecture/dialogue-events.md @@ -1,6 +1,8 @@ # Dialogue event catalog — Claude Code stream-json -**Status:** Wire-level parser contract for the Dialogue feature. Companion to [`dialogue.md`](dialogue.md). +> ⚠️ **Status: Deferred — specification only, not yet implemented.** +> +> Snapshot as of **2026-04-18**. This catalog matches the `claude` CLI schema at that date. Anthropic evolves the stream-json format — before implementing the parser (D-8), **re-fetch the latest Anthropic docs** and compare against this document; update any drifted event shape, add newly introduced event types, and adjust dedup rules if needed. Once D-8 lands with fixture-driven tests, replace this banner with `Status: Implemented` + last-verified date. **Scope:** every event type emitted by `claude -p --output-format stream-json --verbose --include-partial-messages` — schema, UI mapping, dedup rules, and edge cases. This is the authoritative reference for `ClaudeStreamJSONParser` (D-8) and `AgentChatFeature` (D-10). diff --git a/docs/architecture/dialogue.md b/docs/architecture/dialogue.md index 9befe32..6af5d36 100644 --- a/docs/architecture/dialogue.md +++ b/docs/architecture/dialogue.md @@ -1,6 +1,10 @@ # Dialogue — structured chat UI for agent sessions -**Status:** Specification. Implementation decomposed into [EPIC #250](https://github.com/androidbroadcast/Relay/issues/250) with 30 child tasks (D-1 … D-30). This document is the authoritative spec; it is updated in-sync with any PR that deviates from or clarifies the design. +> ⚠️ **Status: Deferred — specification only, not yet implemented.** +> +> Snapshot as of **2026-04-18**. Implementation is decomposed into [EPIC #250](https://github.com/androidbroadcast/Relay/issues/250) (30 child tasks D-1 … D-30) but not scheduled. Until work starts, this document may drift from the current codebase — module names, APIs, and dependencies on `main` can evolve. +> +> **Before picking up the first child task:** re-verify this spec against current `main`. Update affected sections (architecture, package diagram, integration points, dependency versions, risk register) in the same PR that begins implementation. Once the MVP lands, remove this banner and replace with `Status: Implemented` + a "Last updated" date. **Feature slug:** `dialogue`. Code identifiers: `SessionSurface.agentDialogue(.claudeCode)`, `DialogueFeature` (TCA reducer), `DialogueView` (SwiftUI), `DialogueMarkdownView` (markdown renderer wrapper). @@ -8,8 +12,6 @@ - [EPIC #250](https://github.com/androidbroadcast/Relay/issues/250) — task tracking and current status. - [`dialogue-events.md`](dialogue-events.md) — Claude Code stream-json event catalog + UI mapping (wire-level parser contract). -Initial specification date: 2026-04-18. - --- ## Problem summary From fb8af136d6f64011d7df49d18b285ac5ee506f29 Mon Sep 17 00:00:00 2001 From: kirich1409 Date: Sat, 18 Apr 2026 11:41:55 +0300 Subject: [PATCH 03/10] Address code-reviewer findings (PR #281) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major fixes: - Correct the dependency graph: `apple/swift-markdown` sits beside `DesignSystem` under `AgentChatUI`, not below `DesignSystem`. Keeps ISP boundary intact (DS stays markdown-free). - MVP AC #2 no longer names a specific markdown library — references the Dependencies table and the current plan (`apple/swift-markdown` + own renderer) instead. - Replace remaining stale Textual references with the actual chosen path (`apple/swift-markdown`) across TL;DR, recommendation, and risks. Textual remains explicitly called out as rejected in the Dependencies table with rationale. - Prerequisites section now notes that `DSCardModifier` and `StatusBadge` already exist in DesignSystem — only `BlockCodeContainer` is genuinely new. Child issues #251 (D-1) and #252 (D-2) annotated with re-verify-before-implement banners. - `dialogue-events.md` banner restored to the CLAUDE.md template; the catalog-specific re-verify note (re-fetch Anthropic docs before D-8) is a separate subsection below the banner, preserving the policy shape. Minor fixes: - `AgentChat` package row clarifies that `TerminalAbstraction` dependency is for a new `rawStdout` hook added in D-5. - Pre-flight risk row clarifies that `ClaudeEnvironment` whitelist is an extension of the existing module, not an existing API. - Architecture Documents index entries tagged with snapshot date + deferred status. - Worktree cleanup rule adds a pre-removal audit step. --- CLAUDE.md | 6 +++--- docs/architecture/dialogue-events.md | 9 ++++++++- docs/architecture/dialogue.md | 30 +++++++++++++--------------- 3 files changed, 25 insertions(+), 20 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a3eefa5..361e111 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -328,8 +328,8 @@ When starting a task: - `docs/architecture/relay-cloud-architecture.md` — full architecture (23 sections, reviewed 5+ rounds) - `docs/architecture/relay-cloud-decomposition.md` — task decomposition (30 tasks, 5 waves) -- `docs/architecture/dialogue.md` — Dialogue feature spec (structured chat UI for agent sessions; EPIC #250) -- `docs/architecture/dialogue-events.md` — Claude Code stream-json event catalog (parser contract for Dialogue) +- `docs/architecture/dialogue.md` — Dialogue feature spec (structured chat UI for agent sessions; EPIC #250) — **deferred, snapshot 2026-04-18** +- `docs/architecture/dialogue-events.md` — Claude Code stream-json event catalog (parser contract for Dialogue) — **deferred, snapshot 2026-04-18; re-verify against current CLI before implementation** - `docs/testplans/` — 114 test cases across 14 files (see `docs/testplans/00-strategy.md`) - `runner/README.md` — runner setup guide @@ -436,7 +436,7 @@ The banner communicates three things: (1) the doc is aspirational, not yet shipp - **Spec is living after implementation begins** — updated in the same PR as any child-issue PR that deviates from or clarifies the design. Docs describe what IS, not what was planned. - **Status transitions** — when the MVP acceptance criteria are met, replace the `Deferred` banner with `Status: Implemented` + `Last updated: YYYY-MM-DD`. When the feature ships a new major iteration, update the "Last updated" date. - **Epic + docs are the durable artefact** after the worktree is cleaned up. The research report in `swarm-report/` may be removed along with its worktree at any time; everything that must persist already lives on main. -- **Cleanup after decomposition** — when the decomposition PR merges and no implementation is planned in the immediate session, remove the research worktree (`git worktree remove .worktree/`). The branch can stay for history or be deleted; the spec on main is canonical. +- **Cleanup after decomposition** — when the decomposition PR merges and no implementation is planned in the immediate session, remove the research worktree (`git worktree remove .worktree/`). Before removing, audit `swarm-report/*` in the worktree: everything that must persist (spec, diagrams, decisions) has to be in `docs/architecture/` or referenced from an issue body. Anything else is ephemeral and gets discarded with the worktree. The branch can stay for history or be deleted; the spec on main is canonical. - **Retirement** — if a deferred spec is abandoned (product decision to never build, or superseded by a different approach), don't silently delete — replace the banner with `Status: Archived — superseded by ` or `Status: Archived — not pursued`, keep the file for historical context. Remove after one release cycle if no references remain. ### Quality gate diff --git a/docs/architecture/dialogue-events.md b/docs/architecture/dialogue-events.md index 810b13d..a82130d 100644 --- a/docs/architecture/dialogue-events.md +++ b/docs/architecture/dialogue-events.md @@ -2,7 +2,14 @@ > ⚠️ **Status: Deferred — specification only, not yet implemented.** > -> Snapshot as of **2026-04-18**. This catalog matches the `claude` CLI schema at that date. Anthropic evolves the stream-json format — before implementing the parser (D-8), **re-fetch the latest Anthropic docs** and compare against this document; update any drifted event shape, add newly introduced event types, and adjust dedup rules if needed. Once D-8 lands with fixture-driven tests, replace this banner with `Status: Implemented` + last-verified date. +> Snapshot as of **2026-04-18**. Implementation tracked in [EPIC #250](https://github.com/androidbroadcast/Relay/issues/250). +> Until work starts, this document may drift from the current codebase. +> +> **Before picking up the first child task:** re-verify this spec against current `main`. Update affected sections in the same PR that begins implementation. Once the MVP lands, remove this banner and replace with `Status: Implemented` + a "Last updated" date. + +### Additional re-verification step for this document + +This catalog matches the `claude` CLI schema at the snapshot date above. Anthropic evolves the stream-json format independently of Relay. Before implementing the parser (D-8), **re-fetch the latest Anthropic docs** (sources linked in References) and compare event-by-event against this catalog: update any drifted event shape, add newly introduced event types, and adjust dedup rules if needed. Capture the re-verification date in the status banner once work begins. **Scope:** every event type emitted by `claude -p --output-format stream-json --verbose --include-partial-messages` — schema, UI mapping, dedup rules, and edge cases. This is the authoritative reference for `ClaudeStreamJSONParser` (D-8) and `AgentChatFeature` (D-10). diff --git a/docs/architecture/dialogue.md b/docs/architecture/dialogue.md index 6af5d36..f3bf54d 100644 --- a/docs/architecture/dialogue.md +++ b/docs/architecture/dialogue.md @@ -32,7 +32,7 @@ Visual reference — Claude Remote / Claude Desktop redesign; but this is a re-i 4. **ACP (Agent Client Protocol) от Zed — реальный де-факто стандарт** для multi-agent host'ов: 24+ реализаций, Claude / Codex / Gemini / Cline / Goose через адаптеры. В v0.11 (март 2026) под активной разработкой. Для Relay — правильный долгосрочный таргет, **но не MVP**: нет Swift SDK, Claude работает через Node-адаптер `@zed-industries/claude-agent-acp`. 5. **Архитектурное решение: dual-consumer на одном PTY.** К `TerminalSession` добавляется `rawStdout: AsyncStream` (сырые байты до VT-парсинга). Chat-слой подписывается на этот поток, прогоняет через JSONL-парсер, выдаёт `AgentStreamEvent`. SwiftTerm в chat-сессии не рендерится, но PTY-инфраструктура (spawn, reconnect, env, lifecycle) переиспользуется полностью. 6. **Два новых пакета:** `AgentChat` (domain + TCA reducer + JSONL parser, без SwiftUI) и `AgentChatUI` (views + markdown). Runner и proto **не меняются в MVP** — вся семантика на клиенте. -7. **Markdown-рендеринг — Textual (gonzalezreal).** MIT, Swift 6, macOS 15+, AttributedString-бэкенд, встроенная syntax-highlight. Автор сам позиционирует её как преемника `swift-markdown-ui`, который теперь в maintenance mode. Альтернатива — `apple/swift-markdown` (только parser) + собственный SwiftUI-рендер, если Textual окажется сырым в 0.x. +7. **Markdown-рендеринг — `apple/swift-markdown` (AST) + собственный SwiftUI-рендер + Apple `AttributedString(markdown:)` для inline.** Apache-2.0, Apple, bus factor 0. Textual (gonzalezreal) рассматривался первым, но отклонён — см. §Dependencies (3 критичных OPEN issue блокируют streaming use case). `swift-markdown-ui` в maintenance mode. --- @@ -139,7 +139,7 @@ Visual reference — Claude Remote / Claude Desktop redesign; but this is a re-i | Пакет | Содержимое | Зависимости | |---|---|---| -| **`AgentChat`** | Domain: `AgentMessage`, `ToolCall`, `ToolResult`, `ApprovalRequest`, `AgentStreamEvent`, `AgentChatSession` protocol. Parser: `ClaudeStreamJSONParser`. TCA: `AgentChatFeature`. **Без SwiftUI, без gRPC, без SwiftTerm.** | `TerminalAbstraction` (только для `rawStdout` hook), `SharedModels` | +| **`AgentChat`** | Domain: `AgentMessage`, `ToolCall`, `ToolResult`, `ApprovalRequest`, `AgentStreamEvent`, `AgentChatSession` protocol. Parser: `ClaudeStreamJSONParser`. TCA: `AgentChatFeature`. **Без SwiftUI, без gRPC, без SwiftTerm.** | `TerminalAbstraction` — для нового `rawStdout: AsyncStream` в `TerminalSession` (добавляется в D-5); `SharedModels` | | **`AgentChatUI`** | SwiftUI: `AgentChatView`, `UserMessageView`, `AssistantMessageView`, `ToolCallCardView`, `CodeBlockView`, `ThinkingIndicatorView`, `ApprovalPromptView`, `DiffView`, `ChatInputView`, `ChatMarkdownView` (свой SwiftUI renderer поверх `apple/swift-markdown` AST, либо wrapper вокруг LiYanan2004/MarkdownView если spike пройдёт). | `AgentChat`, `DesignSystem`, **apple/swift-markdown** (primary) или **LiYanan2004/MarkdownView** (после spike) | ### Изменения существующих пакетов @@ -189,12 +189,10 @@ TerminalSwiftTerm RemoteTerminal ▲ │ AgentChatUI ──► AgentChat - │ - ▼ - DesignSystem - │ - ▼ - Textual (transitive — только через AgentChatUI) + │ │ + ▼ ▼ + DesignSystem apple/swift-markdown + (transitive — только через AgentChatUI) ``` Циклов нет. `TerminalFeature` не знает про chat. `AgentChat` не знает про SwiftUI. `DesignSystem` не знает про markdown/Claude. @@ -224,7 +222,7 @@ public protocol AgentChatSession: AnyObject, Sendable { | Риск | Severity | Mitigation | |---|---|---| | **Reconnect в chat mode ломает transcript** — VT-snapshot не подходит для JSONL, может оборвать JSON на середине | Critical | Парсер robust к partial lines (буферизация до `\n`), дедуп по Claude `message_id`; в пост-MVP — миграция на server-side топологию с первоклассным chat-snapshot RPC | -| **Агент выпадает в interactive TUI-промпт** — `/login`, OAuth flow, MCP server approval, `claude migrate-installer`, first-run onboarding. Chat UI их не видит и не умеет показать | Major | Pre-flight check через `claude doctor` / API-key проверку перед стартом; при обнаружении missing-auth — explicit error screen «Нужна интерактивная настройка» с CTA «Open as terminal». Whitelist известных pre-conditions поддерживается в `ClaudeEnvironment` | +| **Агент выпадает в interactive TUI-промпт** — `/login`, OAuth flow, MCP server approval, `claude migrate-installer`, first-run onboarding. Chat UI их не видит и не умеет показать | Major | Pre-flight check через `claude doctor` / API-key проверку перед стартом; при обнаружении missing-auth — explicit error screen «Нужна интерактивная настройка» с CTA «Open as terminal». Whitelist известных pre-conditions добавляется как расширение существующего модуля `ClaudeEnvironment` (см. AgentOrchestrator) | | **Schema drift Claude stream-json** — Anthropic может переименовать поле / добавить event type, chat сломается при апдейте CLI | Major | (a) версионирование схемы в парсере — `Codable` с `decodeIfPresent` + tolerance к unknown keys; (b) graceful degradation на unknown event → лог + сырой JSON в debug-панели вместо краша; (c) CI-тест против реального `claude` в последней версии; (d) snapshot-фикстуры с примерами JSONL | | **User confusion от отсутствия toggle** — пользователь запросил «switch mode», получает «pick at creation» | Major | См. раздел «UX divergence»: явный picker + «Duplicate as X» action + release notes. Первая версия — opt-in флаг в Settings, не default | | **Tool-output не помещается в карточку** — `Bash(find /)` выдаёт 500KB, card-view зависает | Major | ToolResultView: truncate на 2KB/40 строк + «Show full» → открывает modal с scroll + virtualization. Отдельный limit для binary/non-UTF8 → показывать summary «N bytes binary» | @@ -247,7 +245,7 @@ public protocol AgentChatSession: AnyObject, Sendable { 1. Ввести `SessionSurface` enum — выбор режима фиксируется при создании, не переключается. 2. Добавить `rawStdout: AsyncStream` в `TerminalSession`, реализовать в обоих адаптерах. -3. Создать пакеты `AgentChat` (domain+TCA) и `AgentChatUI` (SwiftUI+Textual). +3. Создать пакеты `AgentChat` (domain+TCA) и `AgentChatUI` (SwiftUI + `apple/swift-markdown`). 4. Markdown — **apple/swift-markdown (AST) + собственный SwiftUI renderer** в `AgentChatUI`, ИЛИ `LiYanan2004/MarkdownView` после 1–2 дня spike на compatibility. Textual **отклонён** (см. Dependencies table — 3 критичных OPEN issues блокируют streaming use case). JSONL парсить Foundation'ом без сторонних deps. 5. `AgentChatSession` — семантический protocol, готовый к ACP/Codex в будущем. 6. Proto/runner не трогать в MVP. @@ -265,7 +263,7 @@ Claude Code хранит session-state в `~/.claude/projects//2KB. 4. **Ошибки парсера не крашат UI** — unknown event → лог + отображение в debug-панели, сессия продолжается. 5. **Graceful terminate** — kill chat-сессии корректно завершает Claude-процесс, закрывает AsyncStream'ы, освобождает ресурсы (без зомби-процессов). @@ -275,8 +273,8 @@ Claude Code хранит session-state в `~/.claude/projects///