diff --git a/CLAUDE.md b/CLAUDE.md index f076827..d642d8a 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) — **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 @@ -399,8 +401,45 @@ Documentation lives in `docs/` and component-level READMEs (e.g., `runner/README ### Rules - Update docs **in the same PR** as the code change — not as a follow-up - Docs describe what IS (based on code), not what was planned +- **Exception:** deferred feature specs (see §Deferred feature specs below) intentionally document planned state — the deferred-spec banner + re-verification obligation substitute for the "describe what IS" rule until implementation begins - 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. + +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 places a prominent banner immediately after the H1 title (no other content between `# Title` and the banner): + +```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 (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). +- **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/`). 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 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..7e3ce48 --- /dev/null +++ b/docs/architecture/dialogue-events.md @@ -0,0 +1,437 @@ +# Dialogue event catalog — Claude Code stream-json + +> ⚠️ **Status: Deferred — specification only, not yet implemented.** +> +> 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). + +**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-agent-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 DialogueView** (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 использует `--permission-mode acceptEdits` + `--allowedTools` список из `SettingsStore` (см. dialogue.md §Risks). +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 (⌘.) + +**MVP:** `SIGINT` → graceful shutdown, `result` с `is_error=true`. Input идёт через обычный stdin (plain text), без streaming-input control channel. + +**Крайний случай:** `SIGTERM` → `SIGKILL` через 2s → §6.1. + +**Post-MVP enhancement:** `--input-format stream-json` + `SDKControlInterruptRequest` в stdin → CLI graceful stop → `result` с `subtype=error_during_execution`. Откладывается в MVP, т.к. `--input-format stream-json` недокументирован (Anthropic issue #24594) — рискованно строить первую версию на реверсе. Добавить после стабилизации либо при появлении official docs. + +--- + +## 7. Запуск CLI из Relay + +```bash +# MVP — auto-accept edits; pair --permission-mode with --allowedTools whitelist from SettingsStore +# --bare is publicly undocumented — re-verify behaviour during D-20 re-verification +# Post-MVP: add `--input-format stream-json` for ⌘. interrupt via ControlRequest +# (flag is undocumented, Anthropic issue #24594 — use SIGINT in MVP). +claude -p \ + --output-format stream-json \ + --verbose \ + --include-partial-messages \ + --permission-mode acceptEdits \ + --allowedTools "Read,Grep,Glob,LS,Edit,MultiEdit,Write,Bash,WebFetch,WebSearch,Task,TodoWrite,NotebookEdit,SlashCommand,mcp__*" \ + --bare \ + --model claude-opus-4-7 \ + --session-id "$SESSION_UUID" \ + --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..0f671e6 --- /dev/null +++ b/docs/architecture/dialogue.md @@ -0,0 +1,415 @@ +# Dialogue — structured chat UI for agent sessions + +> ⚠️ **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`. + +**Naming convention** (deliberate two-layer split — see §Organizing convention below for the rationale): + +- **User-facing surface → `Dialogue*`:** `SessionSurface.agentDialogue(.claudeCode)`, `DialogueView`, `DialogueMarkdownView`, `DialogueInputView`. These names match the feature name users see. +- **Generic agent-chat domain → `AgentChat*`:** `AgentChat` (package), `AgentChatFeature` (TCA reducer), `AgentChatSession` (protocol), `AgentChatStatus`, `AgentMessage`, `ToolCall`, `AgentStreamEvent`. Kept generic on purpose — second agent (Codex, ACP) will reuse these types. +- **UI package stays `AgentChatUI`** — it hosts Dialogue-specific top-level views plus generic reusable ones (`UserMessageView`, `AssistantMessageView`, `ToolCallCardView`). The package name emphasises the broader agent-chat-UI purpose; inside, Dialogue-named views are the current Dialogue UX, other views are reusable. + +### Organizing convention + +The `AgentChat*` layer is the protocol-and-data layer, reusable by any agent UI we might ship later (Codex, ACP, Aider, etc.). The `Dialogue*` layer is this one specific user-facing UX — the "beautiful chat" design for macOS. If a second agent UI ever ships with a different UX (say, Zed-ACP-style panel), it can consume the same `AgentChatSession` + `AgentChatFeature` + models without touching Dialogue-named types. Conversely, if Dialogue is the only UX we ever ship, the split is cheap insurance with a small mental overhead. + +Do not use `AgentChatView`, `ChatMarkdownView`, `ChatInputView`, or `.agentChat(…)` — those are earlier-draft names from the research stage and have been superseded by the `Dialogue*` / `.agentDialogue(…)` convention. + +**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). + +--- + +## 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 `.agentDialogue(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-рендеринг — `apple/swift-markdown` (AST) + собственный SwiftUI-рендер + Apple `AttributedString(markdown:)` для inline.** Apache-2.0, Apple, maintained by Apple, no single-maintainer risk. Textual (gonzalezreal) рассматривался первым, но отклонён — см. §Dependencies (3 критичных OPEN issue блокируют streaming use case). `swift-markdown-ui` в maintenance mode. + +--- + +## 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 --allowedTools ""` в PTY с нужным workdir/env (`--bare` публично не документирован — перепроверить поведение при re-verification перед D-20). Сырой stdout идёт обычным gRPC-путём в клиент. Клиент форкает байты: SwiftTerm (если surface=.agentTerminal) или `ClaudeStreamJSONParser` (если surface=.agentDialogue). +- **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) — built-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** | **pin specific commit SHA** (Apple не теггирует релизы; обновление — через explicit PR с SHA-bump) | — | ✅ | Apache-2.0 | Parser-only (cmark-gfm + AST) | **Primary**: AST-provider. Гибридный рендер — свой SwiftUI для блоков + Apple `AttributedString(markdown:)` для inline внутри параграфов. Maintained by Apple — no single-maintainer risk. Полный контроль. 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). + +--- + +## Архитектура (итоговый дизайн) + +> **All content below is target state — planned, not yet present on `main`.** +> Packages, types, protocols, flags, and integration points here describe what D-1 … D-30 will create or change. Existing APIs are called out explicitly when referenced; unless marked as existing, every symbol in this section is introduced by the tasks listed. `TerminalSession.rawStdout`, `SessionSurface`, `AgentChatSession`, `AgentChat*`, `DialogueView`, `Presentation` enum — **none of these exist on `main` yet**. Re-verify against the current codebase before implementation (see the deferred-spec banner at the top of this file). + +### Новые пакеты + +| Пакет | Содержимое | Зависимости | +|---|---|---| +| **`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: `DialogueView`, `UserMessageView`, `AssistantMessageView`, `ToolCallCardView`, `CodeBlockView`, `ThinkingIndicatorView`, `ApprovalPromptView`, `DiffView`, `DialogueInputView`, `DialogueMarkdownView` (свой 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). gRPC-обработчик работает **вне `@MainActor`** — эмиссия через `AsyncStream.Continuation` идёт `nonisolated`, по тому же паттерну, что уже используется для `TerminalViewDelegate` методов (см. CLAUDE.md §Gotchas). Избегать лишнего `Task { @MainActor in }` hop на hot path — поток байт и без того throttle'ится парсером. +- **`AgentOrchestrator` / `ClaudeCommandBuilder`** — добавить параметр `surface: SessionSurface`, для `.agentDialogue(.claudeCode)` добавлять флаги `-p --output-format stream-json --verbose --include-partial-messages --bare --permission-mode acceptEdits` (и `--allowedTools` из `SettingsStore`). +- **`SharedModels`** — ввести `SessionSurface` enum: `.shell / .agentTerminal(AgentKind) / .agentDialogue(AgentKind)`. Immutable после создания сессии. +- **`PaneManager`** — `Tab.surface: SessionSurface`. Routing в UI по surface. +- **`AgentSessionFeature.State.presentation: Presentation`** (enum, `.terminal(TerminalFeature.State) | .dialogue(AgentChatFeature.State)`) со scope через `ifCaseLet`. **Это breaking change** в структуре State — существующее поле `terminal: TerminalFeature.State` **заменяется** на `presentation`, не дополняется рядом (регрессионно проверить все существующие Action-case в reducer'е D-21). +- **`SettingsStore`** — ключ `preferredClaudeSurface: SessionSurface = .agentDialogue(.claudeCode)`. +- **`MainFeature` view** — `switch tab.surface` → `TerminalView` либо `DialogueView`. +- **`DesignSystem`** — добавить `BlockCodeContainer` (новый). `DSCardModifier` (через `.dsCard()`) и `StatusBadge` уже существуют — до старта `AgentChatUI` решить в child issues D-1 / D-2: адаптировать существующие под Dialogue или создать новые `Card` / `StatusIndicator`-компоненты. Markdown — **не** в DS (ISP: чтобы settings/welcome/sidebar не тянули markdown-зависимость). + +### 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 apple/swift-markdown + (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 +} + +public enum AgentInput: Sendable { + case text(String) // MVP — plain prompt text + case attachment(Data, mimeType: String) // Post-MVP (image/file via paste) +} +``` + +Первая реализация — `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` (см. 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» | +| **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 | Обернуть в `DialogueMarkdownView` фасад в `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 + `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. + +### Cross-surface session resume (new) + +Claude Code хранит session-state в `~/.claude/projects//.jsonl` **независимо от output-format**. Это значит: + +- Сессия, запущенная в `.agentDialogue(.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. **Создание `.agentDialogue(.claudeCode)` сессии** из UI (picker в New Session flow) запускает `claude -p --output-format stream-json --verbose --include-partial-messages --bare --permission-mode acceptEdits --allowedTools ""` в корректном workdir и парсит JSONL в `AgentChatFeature.State.transcript`. +2. **Progressive markdown rendering** assistant-сообщений через `DialogueMarkdownView` (реализация — см. §Dependencies, текущий план: `apple/swift-markdown` AST + собственный SwiftUI renderer) с 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 покрыты для `DialogueView`, `MessageBubble`, `ToolCallCardView`, `ApprovalPromptView`. + +### Prerequisites (до старта implement-стадии) + +- **DesignSystem components**: `DSCardModifier` (через `.dsCard()`) и `StatusBadge` уже существуют в DS — до старта `AgentChatUI` нужно решить: адаптировать их под Dialogue use case или создать новые `Card` / `StatusIndicator`-компоненты (Wave 0 child issues D-1 / D-2 ревью под это). Реально новый — `BlockCodeContainer` (D-3). +- **Markdown dependency approval**: добавление `apple/swift-markdown` (Apache-2.0) в `Package.swift` требует явного апрува пользователем (CLAUDE.md §Dependencies) — задать вопрос в начале реализации Wave 3. + +### 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 `.agentDialogue`** — после первой версии с 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 на `.agentDialogue`. Обоснование: 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** по умолчанию (стандартная практика для chat UI с LLM). Настраиваемое через `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 | +| **Markdown dependency approval** — `apple/swift-markdown` — новая SPM-зависимость требует явного согласия пользователя | Поднять вопрос в начале Wave 3 | +| 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:** декомпозиция завершена — см. [EPIC #250](https://github.com/androidbroadcast/Relay/issues/250) и 30 child-issues (D-1 … D-30). Dependency-approval для `apple/swift-markdown` поднять при старте Wave 3 (D-12). + +--- + +## 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` (не меняется)