# ReCursor Docs — Full AI Context > Mobile-first companion UI for AI coding workflows, documented as a bridge-first Astro Starlight site. Canonical source documents live in `docs-site/src/content/docs/` in the repository. This artifact concatenates the published documentation corpus for AI-assisted workflows. --- Route: /ReCursor/architecture/bridge-protocol/ Source: docs-site/src/content/docs/architecture/bridge-protocol.md Title: Bridge Protocol Specification Description: WebSocket message protocol between the ReCursor Flutter mobile app and the user-controlled TypeScript bridge server. Bridge-first, no-login: device pairing via QR code, no user accounts. > WebSocket message protocol between the ReCursor Flutter mobile app and the user-controlled TypeScript bridge server. Bridge-first, no-login: device pairing via QR code, no user accounts. --- ## Connection Lifecycle ```mermaid sequenceDiagram participant Mobile as ReCursor App participant Bridge as Bridge Server participant Hooks as Claude Code Hooks participant CC as Claude Code Note over Mobile,CC: Initial Connection Mobile->>Bridge: wss:// connect + auth token Bridge-->>Mobile: connection_ack { version, sessions } Mobile->>Bridge: heartbeat_ping Bridge-->>Mobile: heartbeat_pong Note over Mobile,CC: Claude Code Hook Registration CC->>Hooks: SessionStart event Hooks->>Bridge: HTTP POST /hooks/event Bridge-->>Hooks: 200 OK Bridge->>Mobile: session_started { session_id } ``` --- ## Message Format All messages are JSON objects with a `type` field and an optional `id` for request-response correlation. ```json { "type": "message_type", "id": "unique-msg-id", "timestamp": "2026-03-16T10:32:00Z", "payload": { ... } } ``` --- ## Message Types ### Connection #### `auth` (client -> server) Sent immediately after WebSocket connection opens for device pairing authentication. ```json { "type": "auth", "id": "auth-001", "payload": { "token": "device-pairing-token-xxxxx", "client_version": "1.0.0", "platform": "ios" } } ``` #### `connection_ack` (server -> client) Confirms authentication and connection. Includes detected connection mode for security transparency. ```json { "type": "connection_ack", "id": "auth-001", "payload": { "server_version": "1.0.0", "supported_agents": ["claude-code", "opencode", "aider", "goose"], "connection_mode": "secure_remote", "connection_mode_description": "Tailscale mesh VPN (100.x.x.x)", "bridge_url": "wss://devbox.tailnet.ts.net:3000", "requires_health_verification": true, "active_sessions": [ { "session_id": "sess-abc", "agent": "claude-code", "title": "Bridge startup validation" } ] } } ``` **Connection Mode Values:** - `local_only` — Loopback address (127.0.0.1, ::1) - `private_network` — RFC1918 private IP (10.x, 172.16-31.x, 192.168.x) - `secure_remote` — Tailscale/WireGuard or validated secure tunnel - `direct_public` — Public IP/domain without tunnel (requires acknowledgment) - `misconfigured` — Insecure setup detected (connection will be rejected) #### `health_check` (client -> server) Sent after `connection_ack` to verify connection health before entering main shell. ```json { "type": "health_check", "id": "health-001", "payload": { "timestamp": "2026-03-20T14:32:00Z", "client_nonce": "random-nonce-123", "client_capabilities": ["health_v1", "acknowledgment_v1"] } } ``` #### `health_status` (server -> client) Response to health check with detailed status and any security warnings. ```json { "type": "health_status", "id": "health-001", "payload": { "status": "healthy", "connection_mode": "secure_remote", "warnings": [], "checks": { "tls_valid": true, "clock_sync": true, "version_compatible": true, "token_permissions": true }, "server_timestamp": "2026-03-20T14:32:00.050Z", "latency_ms": 24, "ready": true } } ``` **Direct Public Remote with Warning:** ```json { "type": "health_status", "id": "health-001", "payload": { "status": "warning", "connection_mode": "direct_public", "warnings": ["DIRECT_PUBLIC_CONNECTION"], "warning_details": { "DIRECT_PUBLIC_CONNECTION": "Connection is over public internet without tunnel. Certificate validation is required." }, "checks": { "tls_valid": true, "clock_sync": true, "version_compatible": true, "token_permissions": true }, "ready": false, "requires_acknowledgment": true } } ``` #### `acknowledge_warning` (client -> server) User acknowledgment for security warnings (e.g., direct public remote). ```json { "type": "acknowledge_warning", "id": "ack-001", "payload": { "warning_code": "DIRECT_PUBLIC_CONNECTION", "acknowledged": true, "acknowledged_at": "2026-03-20T14:32:30Z" } } ``` #### `acknowledgment_accepted` (server -> client) Confirmation that warning acknowledgment was accepted. ```json { "type": "acknowledgment_accepted", "id": "ack-001", "payload": { "warning_code": "DIRECT_PUBLIC_CONNECTION", "ready": true, "session_timeout": "8h" } } ``` #### `connection_error` (server -> client) Authentication or connection failure. ```json { "type": "connection_error", "id": "auth-001", "payload": { "code": "AUTH_FAILED", "message": "Invalid or expired token" } } ``` **Error Codes:** - `AUTH_FAILED` — Invalid or expired device pairing token - `INSECURE_TRANSPORT` — Connection attempted over `ws://` instead of `wss://` - `MISCONFIGURED` — Bridge security settings prevent this connection - `VERSION_INCOMPATIBLE` — Client/server protocol version mismatch - `RATE_LIMITED` — Too many connection attempts **Misconfigured Mode Example:** ```json { "type": "connection_error", "id": "auth-001", "payload": { "code": "INSECURE_TRANSPORT", "message": "Bridge requires wss:// (WebSocket Secure). Unencrypted ws:// connections are blocked.", "documentation_url": "https://docs.recursor.dev/security/tls-required", "remediation": "Enable TLS on your bridge server and use wss:// URLs" } } ``` #### `heartbeat_ping` / `heartbeat_pong` Keep-alive messages. Client sends ping, server responds with pong. ```json { "type": "heartbeat_ping", "timestamp": "2026-03-16T10:32:00Z" } { "type": "heartbeat_pong", "timestamp": "2026-03-16T10:32:00Z" } ``` Interval: 15 seconds (configurable). If no pong received within 10 seconds, client triggers reconnect. --- ### Agent Sessions #### `session_start` (client -> server) Start a new agent session or resume an existing one. ```json { "type": "session_start", "id": "req-001", "payload": { "agent": "claude-code", "session_id": null, "working_directory": "/home/user/project", "resume": false } } ``` Set `session_id` and `resume: true` to resume an existing session. #### `session_ready` (server -> client) Agent session is initialized and ready. ```json { "type": "session_ready", "id": "req-001", "payload": { "session_id": "sess-abc123", "agent": "claude-code", "working_directory": "/home/user/project", "branch": "main", "status": "ready" } } ``` #### `session_end` (bidirectional) End a session. Can be initiated by client or server. ```json { "type": "session_end", "payload": { "session_id": "sess-abc123", "reason": "user_request" // or "timeout", "error", "completed" } } ``` --- ### Chat Messages #### `message` (client -> server) Send a chat message to the agent. ```json { "type": "message", "id": "msg-001", "payload": { "session_id": "sess-abc123", "content": "Tighten the bridge startup validation in bridge_setup_screen.dart", "role": "user" } } ``` #### `stream_start` (server -> client) Agent begins streaming a response. ```json { "type": "stream_start", "payload": { "session_id": "sess-abc123", "message_id": "msg-resp-001" } } ``` #### `stream_chunk` (server -> client) Chunk of streamed response content. ```json { "type": "stream_chunk", "payload": { "session_id": "sess-abc123", "message_id": "msg-resp-001", "content": "I'll tighten the bridge startup validation", "is_tool_use": false } } ``` #### `stream_end` (server -> client) Streaming response is complete. ```json { "type": "stream_end", "payload": { "session_id": "sess-abc123", "message_id": "msg-resp-001", "finish_reason": "stop" // or "tool_call", "length", "error" } } ``` --- ### Tool Calls #### `tool_call` (server -> client) Agent wants to use a tool. Sent when Agent SDK initiates tool use. ```json { "type": "tool_call", "id": "tool-001", "payload": { "session_id": "sess-abc123", "tool_call_id": "call-abc123", "tool": "edit_file", "params": { "file_path": "/home/user/project/lib/features/startup/bridge_setup_screen.dart", "old_string": "return wsAllowed(url);", "new_string": "return requireWss(url);" }, "description": "Require secure bridge URLs during startup" } } ``` #### `claude_event` (server -> client) Event from Claude Code Hooks. See [Claude Code Hooks Integration](../../integrations/claude-code-hooks/). ```json { "type": "claude_event", "payload": { "event_type": "PostToolUse", "session_id": "sess-abc123", "timestamp": "2026-03-16T10:32:00Z", "payload": { "tool": "edit_file", "result": { "success": true } } } } ``` #### `approval_required` (server -> client) Tool call requires user approval (from Hooks or Agent SDK). ```json { "type": "approval_required", "id": "tool-001", "payload": { "session_id": "sess-abc123", "tool_call_id": "call-abc123", "tool": "run_command", "params": { "command": "flutter build apk" }, "description": "Build Android APK", "risk_level": "medium", "source": "hooks" // or "agent_sdk" } } ``` #### `approval_response` (client -> server) User's decision on a tool call approval. ```json { "type": "approval_response", "id": "tool-001", "payload": { "session_id": "sess-abc123", "tool_call_id": "call-abc123", "decision": "approved", // or "rejected", "modified" "modifications": null // or modified params } } ``` #### `tool_result` (server -> client) Result of tool execution. ```json { "type": "tool_result", "payload": { "session_id": "sess-abc123", "tool_call_id": "call-abc123", "tool": "edit_file", "result": { "success": true, "content": "File edited successfully", "diff": "... unified diff ..." } } } ``` --- ### Git Operations #### `git_status_request` (client -> server) Request current git status. ```json { "type": "git_status_request", "id": "git-001", "payload": { "session_id": "sess-abc123" } } ``` #### `git_status_response` (server -> client) Current git status. ```json { "type": "git_status_response", "id": "git-001", "payload": { "session_id": "sess-abc123", "branch": "feature/bridge-startup", "ahead": 2, "behind": 0, "is_clean": false, "changes": [ { "path": "lib/features/startup/bridge_setup_screen.dart", "status": "modified", "additions": 5, "deletions": 2 } ] } } ``` #### `git_commit` (client -> server) Create a commit. ```json { "type": "git_commit", "id": "git-002", "payload": { "session_id": "sess-abc123", "message": "Tighten bridge startup validation", "files": ["lib/features/startup/bridge_setup_screen.dart"] // null = all staged } } ``` #### `git_diff` (client -> server) Request diff for files. ```json { "type": "git_diff", "id": "git-003", "payload": { "session_id": "sess-abc123", "files": ["lib/features/startup/bridge_setup_screen.dart"], // null = all changes "cached": false } } ``` #### `git_diff_response` (server -> client) Diff content. ```json { "type": "git_diff_response", "id": "git-003", "payload": { "session_id": "sess-abc123", "files": [ { "path": "lib/features/startup/bridge_setup_screen.dart", "old_path": "lib/features/startup/bridge_setup_screen.dart", "new_path": "lib/features/startup/bridge_setup_screen.dart", "status": "modified", "additions": 5, "deletions": 2, "hunks": [ { "header": "@@ -10,5 +10,5 @@", "old_start": 10, "old_lines": 5, "new_start": 10, "new_lines": 5, "lines": [ { "type": "context", "content": " class BridgeConnectionValidator {" }, { "type": "removed", "content": "- return wsAllowed(url);" }, { "type": "added", "content": "+ return requireWss(url);" }, { "type": "context", "content": " // ..." } ] } ] } ] } } ``` --- ### File Operations #### `file_list` (client -> server) List files in a directory. ```json { "type": "file_list", "id": "file-001", "payload": { "session_id": "sess-abc123", "path": "/home/user/project/lib" } } ``` #### `file_list_response` (server -> client) Directory listing. ```json { "type": "file_list_response", "id": "file-001", "payload": { "session_id": "sess-abc123", "path": "/home/user/project/lib", "entries": [ { "name": "auth.dart", "type": "file", "size": 2048 }, { "name": "models", "type": "directory" } ] } } ``` #### `file_read` (client -> server) Read file content. ```json { "type": "file_read", "id": "file-002", "payload": { "session_id": "sess-abc123", "path": "/home/user/project/lib/features/startup/bridge_setup_screen.dart", "offset": 0, "limit": 100 } } ``` #### `file_read_response` (server -> client) File content. ```json { "type": "file_read_response", "id": "file-002", "payload": { "session_id": "sess-abc123", "path": "/home/user/project/lib/auth.dart", "content": "class AuthService { ... }", "size": 2048, "lines": 45 } } ``` --- ### Notifications #### `notification` (server -> client) Server-initiated notification. ```json { "type": "notification", "id": "notif-001", "payload": { "session_id": "sess-abc123", "notification_type": "approval_required", "title": "Approval needed: Update bridge_setup_screen.dart", "body": "Claude Code wants to tighten bridge URL validation before pairing.", "priority": "high", "data": { "tool_call_id": "tool-001", "screen": "approval_detail" } } } ``` #### `notification_ack` (client -> server) Acknowledge receipt of notifications. ```json { "type": "notification_ack", "payload": { "notification_ids": ["notif-001", "notif-002"] } } ``` --- ### Errors #### `error` (server -> client) Server-side error. ```json { "type": "error", "payload": { "code": "AGENT_ERROR", "message": "Failed to execute tool: permission denied", "session_id": "sess-abc123", "recoverable": true } } ``` --- ## Error Codes | Code | Description | Recoverable | |------|-------------|-------------| | `AUTH_FAILED` | Invalid or expired token | No (re-auth required) | | `SESSION_NOT_FOUND` | Session ID doesn't exist | No | | `AGENT_ERROR` | Agent execution failed | Yes (retry) | | `TOOL_ERROR` | Tool execution failed | Yes (modify params) | | `GIT_ERROR` | Git operation failed | Yes | | `RATE_LIMITED` | Too many requests | Yes (backoff) | | `BRIDGE_ERROR` | Internal bridge error | Yes | --- ## Reconnection Behavior When the mobile app reconnects after disconnection: 1. Client sends `auth` message 2. Server responds with `connection_ack` including `active_sessions` 3. Server replays any queued events (notifications, tool results) 4. Client acknowledges with `notification_ack` --- ## Related Documentation - [Architecture Overview](../system-overview/) — System architecture - [Data Flow](../data-flow/) — Message sequence diagrams - [Claude Code Hooks Integration](../../integrations/claude-code-hooks/) — Hook event format - [Agent SDK Integration](../../integrations/agent-sdk/) — Agent SDK message flow --- *Last updated: 2026-03-17* --- Route: /ReCursor/architecture/data-flow/ Source: docs-site/src/content/docs/architecture/data-flow.md Title: Data Flow Architecture Description: Message flow between ReCursor mobile app, bridge server, and Claude Code via Hooks. > Message flow between ReCursor mobile app, bridge server, and Claude Code via Hooks. --- ## Connection Lifecycle ```mermaid sequenceDiagram participant Mobile as ReCursor App participant Bridge as Bridge Server participant Hooks as Claude Code Hooks participant CC as Claude Code Note over Mobile,CC: Initial Connection Mobile->>Bridge: wss:// connect + auth token Bridge-->>Mobile: connection_ack { version, sessions } Mobile->>Bridge: heartbeat_ping Bridge-->>Mobile: heartbeat_pong Note over Mobile,CC: Claude Code Hook Registration CC->>Hooks: SessionStart event Hooks->>Bridge: HTTP POST /hooks/event Bridge-->>Hooks: 200 OK Bridge->>Mobile: session_started { session_id } ``` --- ## Message Flow: User Sends Message ```mermaid sequenceDiagram participant Mobile as ReCursor App participant Bridge as Bridge Server participant Agent as Agent SDK participant API as Claude API Note over Mobile,API: User sends message via mobile Mobile->>Mobile: Queue in SyncQueue (if offline) Mobile->>Bridge: message { text, session_id } Bridge->>Bridge: Validate session Bridge->>Agent: Forward message Agent->>API: Claude API request API-->>Agent: Stream response loop Streaming Response Agent->>Bridge: stream_chunk { content } Bridge->>Mobile: stream_chunk { content } Mobile->>Mobile: Update UI (streaming text) end Agent-->>Bridge: stream_end Bridge-->>Mobile: stream_end ``` --- ## Message Flow: Tool Use (via Hooks) ```mermaid sequenceDiagram participant Mobile as ReCursor App participant Bridge as Bridge Server participant Hooks as Claude Code Hooks participant CC as Claude Code participant API as Claude API Note over Mobile,API: Claude Code executes tool CC->>CC: ToolUse (e.g., edit_file) CC->>Hooks: PostToolUse event Hooks->>Bridge: HTTP POST /hooks/event Note right of Hooks: { tool, params, result, session_id } Bridge->>Bridge: Queue event (if mobile offline) Bridge->>Mobile: tool_result { tool, result } Mobile->>Mobile: Render OpenCode-style Tool Card Mobile->>Mobile: Update Session Timeline Note over Mobile,API: Tool requires approval CC->>CC: ToolUse with approval_required CC->>Hooks: PreToolUse event Hooks->>Bridge: HTTP POST /hooks/event Bridge->>Mobile: approval_required { tool, description } Mobile->>Mobile: Show approval UI with rich context Mobile->>Bridge: approval_response { decision, modifications } Note right of Mobile: Cannot inject into Claude Code directly Bridge->>Bridge: Queue for Agent SDK session alt Agent SDK Session Active Bridge->>Agent: Forward approval Agent->>API: Continue with approval context else No Agent SDK Session Bridge->>Bridge: Log for manual handling end ``` --- ## Event Types from Hooks ```mermaid flowchart TB subgraph Events["Hook Event Types"] Session["Session Events"] Tool["Tool Events"] User["User Events"] System["System Events"] end subgraph SessionEvents[" " ] SS[SessionStart] SE[SessionEnd] end subgraph ToolEvents[" " ] PTU[PreToolUse] PTU2[PostToolUse] end subgraph UserEvents[" " ] UPS[UserPromptSubmit] end subgraph SystemEvents[" " ] ST[Stop] SS2[SubagentStop] PC[PreCompact] N[Notification] end Session --- SessionEvents Tool --- ToolEvents User --- UserEvents System --- SystemEvents ``` ### Event Mapping to UI | Hook Event | OpenCode UI Component | Mobile Action | |------------|----------------------|---------------| | `SessionStart` | Session timeline | Add session to list | | `SessionEnd` | Session timeline | Mark session ended | | `PostToolUse` | Tool card | Render tool result card | | `PreToolUse` | Approval dialog | Show approval UI | | `UserPromptSubmit` | Chat message | Show user message | | `Stop` | Session status | Show completion status | | `SubagentStop` | Subagent status | Update subagent state | > **Note**: Only confirmed hook events from Claude Code source truth are listed above. See [Claude Code Hooks Integration](../../integrations/claude-code-hooks/) for the complete verified event list. --- ## Reconnection Flow ```mermaid sequenceDiagram participant Mobile as ReCursor App participant Bridge as Bridge Server Note over Mobile,Bridge: Mobile temporarily disconnects Bridge->>Bridge: Queue events from Hooks Mobile->>Mobile: Detect disconnect Mobile->>Mobile: Show "Reconnecting..." UI loop Reconnect Attempts Mobile->>Bridge: wss:// connect alt Bridge Available Bridge-->>Mobile: connection_ack Bridge->>Mobile: Replay queued events Mobile->>Mobile: Process backlog else Bridge Unavailable Mobile->>Mobile: Wait (exponential backoff) end end ``` --- ## Offline Queue Flow ```mermaid flowchart TD A[User Action] --> B{Online?} B -->|Yes| C[Execute Immediately] B -->|No| D[Queue in SyncQueue] D --> E[Show Pending State] F[Connectivity Restored] --> G{Bridge Reachable?} G -->|Yes| H[Flush Queue] H --> I[Mark Synced] G -->|No| J[Keep Queued] C --> K[Update UI] I --> K ``` --- ## Message Format: Hook Events ```json { "event_type": "PostToolUse", "session_id": "sess-abc123", "timestamp": "2026-03-17T10:32:00Z", "payload": { "tool": "edit_file", "params": { "file_path": "/home/user/project/lib/main.dart", "old_string": "void main() {", "new_string": "void main() async {" }, "result": { "success": true, "diff": "... unified diff ..." }, "metadata": { "token_count": 150, "duration_ms": 250 } } } ``` --- ## Message Format: WebSocket Protocol See [Bridge Protocol](../bridge-protocol/) for complete WebSocket message specification. --- ## Related Documentation - [Architecture Overview](../system-overview/) — High-level system architecture - [Claude Code Hooks Integration](../../integrations/claude-code-hooks/) — Hook configuration details - [Agent SDK Integration](../../integrations/agent-sdk/) — Parallel session flow - [Bridge Protocol](../bridge-protocol/) — WebSocket message specification - [Offline Architecture](../../operations/offline-architecture/) — Sync queue implementation --- *Last updated: 2026-03-17* --- Route: /ReCursor/architecture/data-models/ Source: docs-site/src/content/docs/architecture/data-models.md Title: Data Models Description: Drift schemas, Hive models, and domain entities for ReCursor. > Drift schemas, Hive models, and domain entities for ReCursor. --- ## Drift Database Tables (SQLite) ### Sessions Stores agent chat sessions. ```dart class Sessions extends Table { TextColumn get id => text()(); // "sess-abc123" TextColumn get agentType => text()(); // "claude-code", "opencode", etc. TextColumn get agentId => text().nullable()(); // FK to agents table TextColumn get title => text().withDefault(const Constant(''))(); TextColumn get workingDirectory => text()(); TextColumn get branch => text().nullable()(); TextColumn get status => text()(); // "active", "paused", "closed" DateTimeColumn get createdAt => dateTime()(); DateTimeColumn get lastMessageAt => dateTime().nullable()(); DateTimeColumn get updatedAt => dateTime()(); BoolColumn get synced => boolean().withDefault(const Constant(true))(); @override Set get primaryKey => {id}; } ``` ### Messages Stores chat messages within sessions. ```dart class Messages extends Table { TextColumn get id => text()(); // "msg-001" TextColumn get sessionId => text().references(Sessions, #id)(); TextColumn get role => text()(); // "user", "agent", "system" TextColumn get content => text()(); // Full text (markdown) TextColumn get messageType => text() .withDefault(const Constant('text'))(); // "text", "tool_call", "tool_result" TextColumn get metadata => text().nullable()(); // JSON: token count, tool info, etc. DateTimeColumn get createdAt => dateTime()(); DateTimeColumn get updatedAt => dateTime()(); BoolColumn get synced => boolean().withDefault(const Constant(true))(); @override Set get primaryKey => {id}; } ``` ### Agents Stores configured agent connections. ```dart class Agents extends Table { TextColumn get id => text()(); // UUID TextColumn get displayName => text()(); // "Claude Code" TextColumn get agentType => text()(); // "claude-code", "opencode", "aider", "goose", "custom" TextColumn get bridgeUrl => text()(); // "wss://100.78.42.15:3000" TextColumn get authToken => text()(); // Encrypted bridge pairing token (device-bridge auth, not user account) TextColumn get workingDirectory => text().nullable()(); TextColumn get status => text() .withDefault(const Constant('disconnected'))(); // "connected", "disconnected", "inactive" DateTimeColumn get lastConnectedAt => dateTime().nullable()(); DateTimeColumn get createdAt => dateTime()(); DateTimeColumn get updatedAt => dateTime()(); @override Set get primaryKey => {id}; } ``` ### Approvals Stores tool call approval history. ```dart class Approvals extends Table { TextColumn get id => text()(); // "tool-001" TextColumn get sessionId => text().references(Sessions, #id)(); TextColumn get tool => text()(); // "edit_file", "run_command", etc. TextColumn get description => text()(); // Human-readable description TextColumn get params => text()(); // JSON: tool parameters TextColumn get reasoning => text().nullable()(); // Agent's explanation TextColumn get riskLevel => text()(); // "low", "medium", "high", "critical" TextColumn get decision => text()(); // "approved", "rejected", "modified", "pending" TextColumn get modifications => text().nullable()(); // User's modification instructions TextColumn get result => text().nullable()(); // JSON: tool execution result DateTimeColumn get createdAt => dateTime()(); DateTimeColumn get decidedAt => dateTime().nullable()(); BoolColumn get synced => boolean().withDefault(const Constant(true))(); @override Set get primaryKey => {id}; } ``` ### SyncQueue Offline mutation queue. ```dart class SyncQueue extends Table { IntColumn get id => integer().autoIncrement()(); TextColumn get operation => text()(); // "send_message", "approve_tool", "git_command" TextColumn get payload => text()(); // JSON: full operation payload TextColumn get sessionId => text().nullable()(); DateTimeColumn get createdAt => dateTime()(); BoolColumn get synced => boolean().withDefault(const Constant(false))(); IntColumn get retryCount => integer().withDefault(const Constant(0))(); TextColumn get lastError => text().nullable()(); } ``` ### TerminalSessions Stores terminal session metadata. ```dart class TerminalSessions extends Table { TextColumn get id => text()(); // "term-sess-001" TextColumn get name => text()(); // "main", "feature-branch" TextColumn get workingDirectory => text()(); TextColumn get status => text()(); // "active", "closed" DateTimeColumn get createdAt => dateTime()(); DateTimeColumn get lastActivityAt => dateTime().nullable()(); @override Set get primaryKey => {id}; } ``` --- ## Hive Boxes (Key-Value) ### Connection Box ```dart @HiveType(typeId: 1) class BridgeConnectionState { @HiveField(0) final String deviceToken; @HiveField(1) final String bridgeUrl; @HiveField(2) final DateTime pairedAt; @HiveField(3) final String tokenType; // "device_pairing" } ``` ### Connection Box ```dart @HiveType(typeId: 2) class ConnectionState { @HiveField(0) final String status; // "connected", "disconnected", "reconnecting" @HiveField(1) final String? bridgeUrl; @HiveField(2) final DateTime? lastConnectedAt; @HiveField(3) final int reconnectAttempts; } ``` ### Preferences Box ```dart @HiveType(typeId: 3) class UserPreferences { @HiveField(0) final ThemeMode themeMode; @HiveField(1) final String? defaultAgentId; @HiveField(2) final bool notificationsEnabled; @HiveField(3) final bool offlineModeEnabled; } ``` --- ## Domain Entities (Freezed) ### Message ```dart @freezed class Message with _$Message { const factory Message({ required String id, required String sessionId, required MessageRole role, required String content, required MessageType type, required List parts, Map? metadata, required DateTime createdAt, DateTime? updatedAt, @Default(true) bool synced, }) = _Message; factory Message.fromJson(Map json) => _$MessageFromJson(json); } enum MessageRole { user, agent, system } enum MessageType { text, toolCall, toolResult, system } ``` ### MessagePart (OpenCode-style) ```dart @freezed class MessagePart with _$MessagePart { const factory MessagePart.text({ required String content, }) = TextPart; const factory MessagePart.toolUse({ required String tool, required Map params, String? id, }) = ToolUsePart; const factory MessagePart.toolResult({ required String toolCallId, required ToolResult result, }) = ToolResultPart; const factory MessagePart.thinking({ required String content, }) = ThinkingPart; factory MessagePart.fromJson(Map json) => _$MessagePartFromJson(json); } ``` ### ChatSession ```dart @freezed class ChatSession with _$ChatSession { const factory ChatSession({ required String id, required String agentType, String? agentId, @Default('') String title, required String workingDirectory, String? branch, @Default(SessionStatus.active) SessionStatus status, required DateTime createdAt, DateTime? lastMessageAt, DateTime? updatedAt, @Default(true) bool synced, }) = _ChatSession; factory ChatSession.fromJson(Map json) => _$ChatSessionFromJson(json); } enum SessionStatus { active, paused, closed } ``` ### AgentConfig ```dart @freezed class AgentConfig with _$AgentConfig { const factory AgentConfig({ required String id, required String displayName, required AgentType type, required String bridgeUrl, required String authToken, String? workingDirectory, @Default(AgentConnectionStatus.disconnected) AgentConnectionStatus status, DateTime? lastConnectedAt, required DateTime createdAt, required DateTime updatedAt, }) = _AgentConfig; factory AgentConfig.fromJson(Map json) => _$AgentConfigFromJson(json); } enum AgentType { claudeCode, openCode, aider, goose, custom } enum AgentConnectionStatus { connected, disconnected, inactive } ``` ### ToolCall ```dart @freezed class ToolCall with _$ToolCall { const factory ToolCall({ required String id, required String sessionId, required String tool, required Map params, String? description, String? reasoning, @Default(RiskLevel.low) RiskLevel riskLevel, @Default(ApprovalDecision.pending) ApprovalDecision decision, String? modifications, Map? result, required DateTime createdAt, DateTime? decidedAt, }) = _ToolCall; factory ToolCall.fromJson(Map json) => _$ToolCallFromJson(json); } enum RiskLevel { low, medium, high, critical } enum ApprovalDecision { pending, approved, rejected, modified } ``` ### ToolResult ```dart @freezed class ToolResult with _$ToolResult { const factory ToolResult({ required bool success, required String content, Map? metadata, String? error, int? durationMs, }) = _ToolResult; factory ToolResult.fromJson(Map json) => _$ToolResultFromJson(json); } ``` --- ## Git Models ### GitStatus ```dart @freezed class GitStatus with _$GitStatus { const factory GitStatus({ required String branch, required List changes, required int ahead, required int behind, required bool isClean, }) = _GitStatus; factory GitStatus.fromJson(Map json) => _$GitStatusFromJson(json); } ``` ### GitFileChange ```dart @freezed class GitFileChange with _$GitFileChange { const factory GitFileChange({ required String path, required FileChangeStatus status, int? additions, int? deletions, String? diff, }) = _GitFileChange; factory GitFileChange.fromJson(Map json) => _$GitFileChangeFromJson(json); } enum FileChangeStatus { modified, added, deleted, untracked, renamed } ``` ### GitBranch ```dart @freezed class GitBranch with _$GitBranch { const factory GitBranch({ required String name, required bool isCurrent, String? upstream, int? ahead, int? behind, }) = _GitBranch; factory GitBranch.fromJson(Map json) => _$GitBranchFromJson(json); } ``` --- ## Diff Models ### DiffFile ```dart @freezed class DiffFile with _$DiffFile { const factory DiffFile({ required String path, required String oldPath, required String newPath, required FileChangeStatus status, required int additions, required int deletions, required List hunks, String? oldMode, String? newMode, }) = _DiffFile; factory DiffFile.fromJson(Map json) => _$DiffFileFromJson(json); } ``` ### DiffHunk ```dart @freezed class DiffHunk with _$DiffHunk { const factory DiffHunk({ required String header, required int oldStart, required int oldLines, required int newStart, required int newLines, required List lines, }) = _DiffHunk; factory DiffHunk.fromJson(Map json) => _$DiffHunkFromJson(json); } ``` ### DiffLine ```dart @freezed class DiffLine with _$DiffLine { const factory DiffLine({ required DiffLineType type, required String content, int? oldLineNumber, int? newLineNumber, }) = _DiffLine; factory DiffLine.fromJson(Map json) => _$DiffLineFromJson(json); } enum DiffLineType { context, added, removed } ``` --- ## File Tree Models ### FileTreeNode ```dart @freezed class FileTreeNode with _$FileTreeNode { const factory FileTreeNode({ required String name, required String path, required FileNodeType type, List? children, int? size, DateTime? modifiedAt, String? content, }) = _FileTreeNode; factory FileTreeNode.fromJson(Map json) => _$FileTreeNodeFromJson(json); } enum FileNodeType { file, directory } ``` --- ## Hook Event Models ### HookEvent ```dart @freezed class HookEvent with _$HookEvent { const factory HookEvent({ required String eventType, required String sessionId, required DateTime timestamp, required Map payload, }) = _HookEvent; factory HookEvent.fromJson(Map json) => _$HookEventFromJson(json); } ``` ### PostToolUseEvent ```dart @freezed class PostToolUseEvent with _$PostToolUseEvent { const factory PostToolUseEvent({ required String tool, required Map toolInput, required ToolResult result, Map? metadata, }) = _PostToolUseEvent; factory PostToolUseEvent.fromJson(Map json) => _$PostToolUseEventFromJson(json); } ``` ### PreToolUseEvent ```dart @freezed class PreToolUseEvent with _$PreToolUseEvent { const factory PreToolUseEvent({ required String tool, required Map toolInput, required String riskLevel, required String description, required bool requiresApproval, }) = _PreToolUseEvent; factory PreToolUseEvent.fromJson(Map json) => _$PreToolUseEventFromJson(json); } ``` --- ## Related Documentation - [Project Structure](../project-structure/) — Flutter directory layout - [Bridge Protocol](../bridge-protocol/) — WebSocket message specification - [Offline Architecture](../../operations/offline-architecture/) — Sync and storage patterns - [Claude Code Hooks Integration](../../integrations/claude-code-hooks/) — Event models - [OpenCode UI Patterns](../../integrations/opencode-ui-patterns/) — UI component data --- *Last updated: 2026-03-17* --- Route: /ReCursor/architecture/project-structure/ Source: docs-site/src/content/docs/architecture/project-structure.md Title: Project Structure Description: Flutter directory layout and module organization for ReCursor. > Flutter directory layout and module organization for ReCursor. --- ## Top-Level Layout ``` recursor/ ├── apps/ │ └── mobile/ # Flutter mobile app (iOS + Android) │ ├── android/ │ ├── ios/ │ ├── lib/ │ │ ├── main.dart # App entry point │ │ ├── app.dart # MaterialApp, router, theme │ │ ├── core/ # App-wide infrastructure │ │ ├── features/ # Feature modules │ │ └── shared/ # Shared UI components │ ├── test/ # Unit + widget tests │ ├── integration_test/ # Integration tests │ ├── assets/ # Fonts, images, certificates │ ├── pubspec.yaml │ └── analysis_options.yaml │ ├── packages/ │ └── bridge/ # TypeScript WebSocket bridge server │ ├── src/ │ │ ├── server.ts # WebSocket server entry │ │ ├── agents/ # Agent adapters (Claude Code, OpenCode, etc.) │ │ ├── hooks/ # Claude Code Hooks receiver │ │ ├── git/ # Git operation handlers │ │ ├── terminal/ # Terminal session manager │ │ ├── auth/ # Device token validation, rate limiting │ │ └── notifications/ # Event queue + WebSocket dispatch │ ├── package.json │ └── tsconfig.json │ ├── docs-site/ # Documentation (Astro Starlight site) │ └── src/content/docs/ # Canonical documentation source ├── .github/ │ └── workflows/ # CI/CD pipelines │ ├── test.yml # PR test pipeline │ └── deploy.yml # Build + deploy pipeline ├── fastlane/ # Fastlane config (iOS + Android) └── README.md ``` --- ## Flutter App Structure (`apps/mobile/lib/`) ### `core/` — App-Wide Infrastructure ``` core/ ├── config/ │ ├── app_config.dart # Environment config (dev, staging, prod) │ ├── router.dart # GoRouter route definitions │ └── theme.dart # Material theme, colors, typography │ ├── network/ │ ├── websocket_service.dart # WebSocket client (connect, reconnect, heartbeat) │ ├── websocket_messages.dart # Message type definitions (from bridge-protocol.md) │ └── connection_state.dart # Connection state enum + notifier │ ├── providers/ │ ├── token_storage_provider.dart # Secure bridge token storage provider │ └── websocket_provider.dart # Shared WebSocket service providers │ ├── storage/ │ ├── secure_token_storage.dart # flutter_secure_storage wrapper for bridge pairing │ ├── database.dart # Drift database definition │ ├── tables/ # Drift table definitions │ │ ├── sessions.dart │ │ ├── messages.dart │ │ ├── agents.dart │ │ ├── approvals.dart │ │ └── sync_queue.dart │ ├── daos/ # Data access objects │ │ ├── session_dao.dart │ │ ├── message_dao.dart │ │ └── sync_dao.dart │ └── preferences.dart # Hive key-value store wrapper │ ├── notifications/ │ ├── notification_service.dart # Local notification setup (flutter_local_notifications) │ ├── notification_handler.dart # WebSocket event -> in-app banner / local notification routing │ └── notification_center.dart # In-app notification list, unread count, persistence │ └── sync/ ├── sync_service.dart # Offline queue flush + pull logic ├── sync_queue.dart # Queue operations (enqueue, dequeue, retry) └── conflict_resolver.dart # Last-write-wins + user prompt ``` ### `features/` — Feature Modules Each feature follows a consistent internal structure: ``` features// ├── data/ │ ├── models/ # Data transfer objects, JSON serialization │ └── repositories/ # Repository implementations ├── domain/ │ ├── entities/ # Domain models (immutable, no JSON) │ └── providers/ # Riverpod providers (state + logic) └── presentation/ ├── screens/ # Full-page widgets ├── widgets/ # Feature-specific reusable widgets └── controllers/ # UI logic (if needed beyond providers) ``` Feature modules: ``` features/ ├── chat/ # Agent chat interface │ ├── data/ │ │ ├── models/ │ │ │ ├── chat_message.dart │ │ │ └── chat_session.dart │ │ └── repositories/ │ │ └── chat_repository.dart │ ├── domain/ │ │ ├── entities/ │ │ │ └── message.dart │ │ └── providers/ │ │ ├── chat_provider.dart │ │ └── session_provider.dart │ └── presentation/ │ ├── screens/ │ │ ├── chat_screen.dart │ │ └── session_list_screen.dart │ └── widgets/ │ ├── message_bubble.dart │ ├── streaming_text.dart │ ├── chat_input_bar.dart │ ├── tool_card.dart # OpenCode-style tool card │ ├── message_part.dart # OpenCode-style message parts │ └── voice_input_sheet.dart │ ├── diff/ # Code diff viewer │ ├── data/ │ │ └── repositories/ │ │ └── diff_repository.dart │ ├── domain/ │ │ └── providers/ │ │ └── diff_provider.dart │ └── presentation/ │ ├── screens/ │ │ └── diff_viewer_screen.dart │ └── widgets/ │ ├── diff_viewer.dart # OpenCode-style diff viewer │ ├── diff_file_card.dart │ ├── diff_hunk_view.dart │ └── syntax_highlighted_text.dart │ ├── session/ # Session management │ ├── data/ │ │ └── repositories/ │ │ └── session_repository.dart │ ├── domain/ │ │ └── providers/ │ │ └── session_provider.dart │ └── presentation/ │ ├── screens/ │ │ └── session_detail_screen.dart │ └── widgets/ │ ├── session_timeline.dart # OpenCode-style timeline │ ├── session_card.dart │ └── event_badge.dart │ ├── git/ # Git operations │ ├── data/ │ │ └── repositories/ │ │ └── git_repository.dart │ ├── domain/ │ │ └── providers/ │ │ └── git_provider.dart │ └── presentation/ │ ├── screens/ │ │ ├── commit_screen.dart │ │ └── branch_screen.dart │ └── widgets/ │ ├── git_status_card.dart │ └── file_change_tile.dart │ ├── approvals/ # Tool call approvals │ ├── data/ │ │ └── repositories/ │ │ └── approval_repository.dart │ ├── domain/ │ │ └── providers/ │ │ └── approval_provider.dart │ └── presentation/ │ ├── screens/ │ │ └── approval_detail_screen.dart │ └── widgets/ │ ├── approval_card.dart │ ├── risk_indicator.dart │ └── modification_editor.dart │ ├── terminal/ # Terminal session │ ├── data/ │ │ └── repositories/ │ │ └── terminal_repository.dart │ ├── domain/ │ │ └── providers/ │ │ └── terminal_provider.dart │ └── presentation/ │ ├── screens/ │ │ └── terminal_screen.dart │ └── widgets/ │ ├── terminal_output.dart │ └── ansi_renderer.dart │ ├── agents/ # Agent management │ ├── data/ │ │ └── repositories/ │ │ └── agent_repository.dart │ ├── domain/ │ │ └── providers/ │ │ └── agent_provider.dart │ └── presentation/ │ ├── screens/ │ │ ├── agent_list_screen.dart │ │ └── agent_config_screen.dart │ └── widgets/ │ └── agent_card.dart │ ├── startup/ # Bridge-first launch and pairing restore │ ├── domain/ │ │ └── bridge_startup_controller.dart │ └── presentation/ │ └── screens/ │ ├── splash_screen.dart │ └── bridge_setup_screen.dart │ └── settings/ # App settings └── presentation/ ├── screens/ │ └── settings_screen.dart └── widgets/ └── setting_tile.dart ``` ### `shared/` — Shared UI Components ``` shared/ ├── widgets/ │ ├── loading_indicator.dart # Consistent loading states │ ├── error_card.dart # Error display │ ├── empty_state.dart # Empty list placeholder │ ├── connection_status_bar.dart # Online/offline indicator │ ├── code_block.dart # Syntax-highlighted code │ ├── expandable_card.dart # Reusable expandable pattern │ └── markdown_view.dart # Markdown rendering │ ├── constants/ │ ├── colors.dart # App color palette │ ├── typography.dart # Text styles │ └── dimens.dart # Spacing, sizing constants │ └── utils/ ├── date_formatter.dart # Date/time formatting ├── diff_parser.dart # Unified diff parsing └── ansi_parser.dart # ANSI color code parsing ``` --- ## Bridge Server Structure (`packages/bridge/src/`) ``` bridge/ ├── server.ts # Express + WebSocket server entry ├── config.ts # Environment configuration ├── types.ts # TypeScript type definitions │ ├── websocket/ │ ├── server.ts # WebSocket server setup │ ├── connection_manager.ts # Client connection tracking │ └── message_handler.ts # Message routing │ ├── hooks/ │ ├── receiver.ts # Claude Code Hooks HTTP endpoint │ ├── validator.ts # Event validation │ └── event_queue.ts # Event queuing for offline replay │ ├── agents/ │ ├── agent_sdk_adapter.ts # Agent SDK integration │ ├── session_manager.ts # Session lifecycle management │ └── tool_executor.ts # Tool execution wrapper │ ├── git/ │ ├── git_service.ts # Git operations │ └── diff_parser.ts # Diff generation │ ├── terminal/ │ ├── terminal_manager.ts # Terminal session management │ └── output_stream.ts # Terminal output streaming │ ├── auth/ │ ├── token_validator.ts # Device pairing token validation │ └── rate_limiter.ts # Rate limiting │ └── notifications/ ├── event_bus.ts # Internal event bus └── dispatcher.ts # WebSocket dispatch ``` --- ## Key Principles 1. **Feature-Based Organization**: Each feature is self-contained with its own data, domain, and presentation layers. 2. **Clean Architecture**: Dependencies flow inward: - Presentation depends on Domain - Domain depends on Data - Data depends on Core 3. **Riverpod for State**: All state management uses Riverpod providers, defined in `domain/providers/`. 4. **Repository Pattern**: All data access goes through repositories, which abstract local (Drift/Hive) vs. remote (WebSocket) sources. 5. **OpenCode UI Patterns**: UI components follow OpenCode patterns (tool cards, diff viewer, session timeline). --- ## Related Documentation - [Data Models](../data-models/) — Drift schemas and domain entities - [Architecture Overview](../system-overview/) — System architecture - [OpenCode UI Patterns](../../integrations/opencode-ui-patterns/) — UI component mapping --- *Last updated: 2026-03-17* --- Route: /ReCursor/architecture/system-overview/ Source: docs-site/src/content/docs/architecture/system-overview.md Title: Architecture Overview Description: System architecture for ReCursor: a Flutter mobile app with OpenCode-like UI. Bridge-first, no-login: connects to your user-controlled desktop bridge via secure tunnel. > System architecture for ReCursor: a Flutter mobile app with OpenCode-like UI. Bridge-first, no-login: connects to your user-controlled desktop bridge via secure tunnel. --- ## System Context ```mermaid flowchart TB subgraph Mobile["📱 ReCursor Flutter App"] UI["OpenCode-like UI Layer"] State["Riverpod State Management"] WSClient["WebSocket Client"] end subgraph Desktop["💻 Development Machine"] Bridge["ReCursor Bridge Server\n(TypeScript/Node.js)"] subgraph Integration["Claude Code Integration"] Hooks["Hooks System\n(HTTP Event Observer)"] AgentSDK["Agent SDK\n(Parallel Session)"] CC["Claude Code CLI"] end end subgraph Anthropic["☁️ Anthropic Services"] API["Claude API"] end UI <--> State State <--> WSClient WSClient <-->|wss:// (Tailscale/WireGuard)| Bridge Bridge <-->|HTTP POST| Hooks Hooks -->|Observes| CC Bridge <-->|Optional| AgentSDK AgentSDK <-->|API Calls| API CC <-->|Internal| API ``` --- ## Key Architectural Decisions ### 1. Claude Code Integration Strategy | Approach | Status | Notes | |----------|--------|-------| | Direct Remote Control Protocol | ❌ Not Available | First-party only (claude.ai/code, official apps). No public API for third-party clients. | | **Claude Code Hooks** | ✅ Supported | HTTP-based event observation (one-way) | | **Agent SDK** | ✅ Supported | Parallel agent sessions (not mirroring) | | MCP (Model Context Protocol) | ✅ Supported | Tool interoperability | **Selected Architecture:** Hybrid approach using Hooks for event observation + Agent SDK for parallel sessions. ReCursor does not claim to mirror or control existing Claude Code sessions. ### 2. UI/UX Pattern ReCursor adopts **OpenCode's UI patterns** for the mobile interface: - **Tool Cards**: Rich, interactive cards for tool use and results - **Diff Viewer**: Syntax-highlighted unified/side-by-side diffs - **Session Timeline**: Visual timeline of agent actions and decisions - **Chat Interface**: Streaming text with markdown rendering ### 3. Communication Pattern ``` Mobile App <--WebSocket--> Bridge Server <--HTTP--> Claude Code Hooks ↑ | | ↓ └────────────────────────────────────── Observes Events ``` - **WebSocket**: Bidirectional, real-time communication between mobile and bridge - **HTTP Hooks**: One-way event streaming from Claude Code to bridge - **No Direct Mobile-to-Claude-Code**: All communication flows through the bridge --- ## Component Responsibilities ### ReCursor Flutter App | Component | Responsibility | |-----------|--------------| | UI Layer | Render OpenCode-style tool cards, diff viewer, timeline | | State Management | Riverpod providers for sessions, messages, connection | | WebSocket Client | Connect to bridge, handle reconnections, heartbeat | | Local Storage | Drift for persistence, Hive for caching | ### Bridge Server | Component | Responsibility | |-----------|--------------| | WebSocket Server | Accept mobile connections, manage sessions | | Event Queue | Buffer events from Hooks, replay on reconnect | | HTTP Endpoint | Receive events from Claude Code Hooks | | Agent SDK Adapter | Optional parallel session management | ### Claude Code Integration | Component | Responsibility | |-----------|--------------| | Hooks | POST events to bridge (tool use, messages, session state) | | Agent SDK | Parallel agent session (if user wants independent agent) | | Claude Code CLI | Source of truth for actual coding session | --- ## Security Model ```mermaid flowchart LR subgraph Network["Network Layers"] WireGuard["WireGuard/Tailscale\n(Network Layer)"] TLS["TLS 1.3\n(Transport Layer)"] Auth["Device Pairing Token\n(Application Layer)"] end Phone["📱 Mobile"] --> WireGuard WireGuard --> TLS TLS --> Auth Auth --> Bridge["Bridge Server"] ``` 1. **Network Layer**: Tailscale/WireGuard mesh VPN (or your preferred secure tunnel) 2. **Transport Layer**: WSS (WebSocket Secure) with TLS 1.3 3. **Application Layer**: Device pairing token on WebSocket handshake (no user accounts, no login) ### Connection Mode Detection After establishing the WebSocket connection, the bridge analyzes the connection to determine the **connection mode**. This informs the user of the security posture and triggers appropriate warnings: | Mode | Detection Criteria | Security Level | User Experience | |------|-------------------|----------------|-----------------| | **Local-only** | Loopback address (`127.0.0.1`, `::1`) | ✅ Secure | House icon indicator | | **Private network** | RFC1918 private IP (10.x, 172.16-31.x, 192.168.x) | ✅ Secure | WiFi icon indicator | | **Secure remote** | Tailscale/WireGuard IP (100.x.x.x) or validated tunnel domain | ✅ Secure | Shield icon indicator | | **Direct public remote** | Public IP or domain without tunnel validation | ⚠️ Warning | Warning triangle, requires acknowledgment | | **Misconfigured** | `ws://` instead of `wss://`, or other insecure setup | ❌ Blocked | Error screen, connection refused | **Health Verification**: After `connection_ack`, the client sends a `health_check` message. The bridge responds with `health_status` including the detected connection mode. The app displays the mode indicator and, for "direct public remote," requires explicit user acknowledgment before proceeding to the main shell. --- ## Data Flow Summary ### Outbound (Mobile → Agent) 1. User sends message from mobile app 2. Message queued locally (if offline) 3. WebSocket transmits to bridge 4. Bridge forwards to Agent SDK session (if active) 5. Agent SDK calls Claude API ### Inbound (Agent → Mobile) 1. Claude Code executes tool/action 2. Hooks POST event to bridge 3. Bridge queues event (if mobile disconnected) 4. WebSocket transmits to mobile 5. UI renders OpenCode-style component --- ## Limitations & Constraints | Constraint | Impact | Mitigation | |------------|--------|------------| | Hooks are one-way | Cannot inject messages into Claude Code | Use Agent SDK for parallel session | | No session mirroring | Mobile sees events but not full context | Hooks include rich event metadata | | Requires local Claude Code | Cannot work without desktop agent | Clear user messaging, offline queue | --- ## Related Documentation - [Data Flow Details](../data-flow/) — Message-level sequence diagrams - [Claude Code Hooks Integration](../../integrations/claude-code-hooks/) — Hook configuration - [Agent SDK Integration](../../integrations/agent-sdk/) — Parallel session setup - [OpenCode UI Patterns](../../integrations/opencode-ui-patterns/) — UI component mapping - [Bridge Protocol](../bridge-protocol/) — WebSocket message specification - [Security Architecture](../../operations/security-architecture/) — Security implementation details --- *Last updated: 2026-03-17* --- Route: /ReCursor/integrations/agent-sdk/ Source: docs-site/src/content/docs/integrations/agent-sdk.md Title: Agent SDK Integration Description: Using the Claude Agent SDK for parallel agent sessions in ReCursor. This is a supported integration path — ReCursor does not claim to mirror or control existing Claude Code sessions via unsupported Remote Control protocols. > Using the Claude Agent SDK for parallel agent sessions in ReCursor. This is a supported integration path — ReCursor does not claim to mirror or control existing Claude Code sessions via unsupported Remote Control protocols. --- ## Overview The **Claude Agent SDK** (`@anthropic-ai/claude-agent-sdk`) is the officially supported way to build agentic applications that interact with Claude. ReCursor uses the Agent SDK to create **parallel agent sessions** that can receive user input from the mobile app and execute tools independently. > **Key Concept**: Agent SDK sessions are **parallel**, not mirrored. They exist alongside Claude Code sessions rather than controlling them. --- ## Architecture ```mermaid flowchart TB subgraph Mobile["📱 ReCursor App"] UI["OpenCode-style UI"] WSClient["WebSocket Client"] end subgraph Desktop["💻 Development Machine"] Bridge["Bridge Server"] subgraph Sessions["Agent Sessions"] CC["Claude Code CLI\n(User's main session)"] SDK["Agent SDK Session\n(Parallel, ReCursor-managed)"] end end subgraph Anthropic["☁️ Anthropic API"] API["Claude API"] end UI <--> WSClient WSClient <-->|WebSocket| Bridge Bridge <-->|Agent SDK| SDK SDK <-->|API Calls| API CC <-->|Internal| API ``` --- ## When to Use Agent SDK | Scenario | Solution | |----------|----------| | User wants to chat with agent from mobile | ✅ Agent SDK session | | User wants to approve tool calls from mobile | ⚠️ Hooks + Agent SDK (see below) | | User wants to see what Claude Code is doing | ✅ Hooks | | User wants to control existing Claude Code session | ❌ Not supported (Remote Control is first-party only) | ### Hybrid Approach For full functionality, ReCursor uses both: 1. **Hooks** — Observe Claude Code events (tool use, session state) 2. **Agent SDK** — Receive user input and execute independent actions When a user "approves" a tool call in the mobile app: - The approval is sent to the Agent SDK session - The Agent SDK session executes a similar tool - Claude Code continues independently (Hooks show what it did) --- ## Agent SDK Setup ### Installation ```bash npm install @anthropic-ai/claude-agent-sdk ``` ### Basic Session ```typescript import { Agent } from '@anthropic-ai/claude-agent-sdk'; import { ReadTool, EditTool, BashTool } from '@anthropic-ai/claude-agent-sdk/tools'; const agent = new Agent({ model: 'claude-3-5-sonnet-20241022', tools: [new ReadTool(), new EditTool(), new BashTool()], workingDirectory: '/home/user/project', }); // Start a conversation const response = await agent.run({ messages: [{ role: 'user', content: 'Tighten the bridge startup validation in bridge_setup_screen.dart' }], }); ``` ### Integration with Bridge Server ```typescript // Bridge server manages Agent SDK sessions import { Agent } from '@anthropic-ai/claude-agent-sdk'; import { EventEmitter } from 'events'; class AgentSessionManager { private sessions: Map = new Map(); private eventEmitter: EventEmitter = new EventEmitter(); async createSession(sessionId: string, config: SessionConfig): Promise { const agent = new Agent({ model: config.model || 'claude-3-5-sonnet-20241022', tools: this.loadTools(config.toolAllowlist), workingDirectory: config.workingDirectory, }); this.sessions.set(sessionId, agent); // Forward events to mobile agent.on('tool_use', (event) => { this.eventEmitter.emit('tool-use', { sessionId, event }); }); agent.on('message', (event) => { this.eventEmitter.emit('message', { sessionId, event }); }); } async sendMessage(sessionId: string, message: string): Promise { const agent = this.sessions.get(sessionId); if (!agent) throw new Error('Session not found'); // Stream response back to mobile const stream = agent.run({ messages: [{ role: 'user', content: message }], }); for await (const chunk of stream) { this.eventEmitter.emit('stream_chunk', { sessionId, chunk }); } } async executeTool(sessionId: string, toolCall: ToolCall): Promise { const agent = this.sessions.get(sessionId); if (!agent) throw new Error('Session not found'); return agent.executeTool(toolCall); } } ``` --- ## Message Flow ### User Sends Message ```mermaid sequenceDiagram participant Mobile as ReCursor App participant Bridge as Bridge Server participant SDK as Agent SDK participant API as Claude API Mobile->>Bridge: message { text, session_id } Bridge->>SDK: agent.run({ messages }) SDK->>API: Claude API request API-->>SDK: Stream response loop Streaming SDK->>Bridge: Stream chunk Bridge->>Mobile: stream_chunk Mobile->>Mobile: Update UI end SDK-->>Bridge: Complete Bridge-->>Mobile: stream_end ``` ### Tool Execution ```mermaid sequenceDiagram participant Mobile as ReCursor App participant Bridge as Bridge Server participant SDK as Agent SDK participant Tools as Tool Implementations SDK->>SDK: Tool use requested SDK->>Bridge: tool_use event Bridge->>Mobile: approval_required alt Approved Mobile->>Bridge: approval_response { approved } Bridge->>SDK: Continue execution SDK->>Tools: Execute tool Tools-->>SDK: Result SDK->>Bridge: tool_result Bridge->>Mobile: Render Tool Card else Rejected Mobile->>Bridge: approval_response { rejected } Bridge->>SDK: Halt execution SDK-->>Bridge: Error end ``` --- ## Tool Configuration ### Available Tools ```typescript import { ReadTool, EditTool, BashTool, GlobTool, GrepTool, LSTool, } from '@anthropic-ai/claude-agent-sdk/tools'; const tools = [ new ReadTool(), // Read file contents new EditTool(), // Edit files (find/replace) new BashTool({ // Execute shell commands allowedCommands: ['git', 'flutter', 'npm'], // Optional allowlist }), new GlobTool(), // File globbing new GrepTool(), // Text search new LSTool(), // List directory contents ]; ``` ### Custom Tools ```typescript import { Tool, ToolInput, ToolResult } from '@anthropic-ai/claude-agent-sdk'; class DeployTool implements Tool { name = 'deploy_app'; description = 'Deploy the application to staging/production'; async execute(input: ToolInput): Promise { const { environment, version } = input.parameters; // Custom deployment logic const result = await this.deploy(environment, version); return { success: result.success, content: result.message, }; } } ``` --- ## Session Management ### Session Lifecycle ```typescript interface SessionLifecycle { // Create new session async createSession(config: SessionConfig): Promise; // Resume existing session (if supported) async resumeSession(sessionId: string): Promise; // Pause (keep context, stop processing) async pauseSession(sessionId: string): Promise; // Close (cleanup resources) async closeSession(sessionId: string): Promise; } ``` ### Session Context ```typescript interface SessionContext { sessionId: string; workingDirectory: string; gitBranch?: string; toolAllowlist: string[]; model: string; temperature: number; // Conversation history (for resuming) messageHistory: Message[]; } ``` --- ## Configuration ### Environment Variables ```bash # Bridge server .env ANTHROPIC_API_KEY=sk-ant-... AGENT_MODEL=claude-3-5-sonnet-20241022 AGENT_MAX_ITERATIONS=25 AGENT_TEMPERATURE=0.7 ``` ### Per-Session Configuration ```typescript interface SessionConfig { model?: string; temperature?: number; maxIterations?: number; toolAllowlist?: string[]; workingDirectory: string; initialInstructions?: string; } ``` --- ## Error Handling ### Common Errors | Error | Cause | Solution | |-------|-------|----------| | `APIError` | Invalid API key or rate limit | Check API key, implement backoff | | `ToolError` | Tool execution failed | Show error in tool card | | `TimeoutError` | Tool took too long | Set appropriate timeouts | | `SessionError` | Session ID not found | Validate session on mobile | ### Retry Strategy ```typescript async function withRetry( operation: () => Promise, maxRetries: number = 3 ): Promise { for (let i = 0; i < maxRetries; i++) { try { return await operation(); } catch (error) { if (i === maxRetries - 1) throw error; await delay(Math.pow(2, i) * 1000); // Exponential backoff } } throw new Error('Unreachable'); } ``` --- ## Security Considerations ### API Key Management - Store `ANTHROPIC_API_KEY` in bridge server environment only - Never expose to mobile app - Rotate keys regularly ### Tool Restrictions ```typescript // Restrict dangerous tools const safeTools = [ new ReadTool(), new EditTool(), new BashTool({ allowedCommands: ['git', 'flutter', 'npm'], blockedCommands: ['rm -rf', 'sudo', 'chmod'], }), ]; ``` ### Working Directory Isolation ```typescript // Verify working directory is within allowed paths function validateWorkingDirectory(dir: string): void { const allowedRoot = process.env.ALLOWED_PROJECT_ROOT; if (!dir.startsWith(allowedRoot)) { throw new Error('Working directory outside allowed root'); } } ``` --- ## Related Documentation - [Claude Code Hooks Integration](../claude-code-hooks/) — Event observation - [Architecture Overview](../../architecture/system-overview/) — System architecture - [Data Flow](../../architecture/data-flow/) — Message sequence diagrams - [Bridge Protocol](../../architecture/bridge-protocol/) — WebSocket message specification --- *Last updated: 2026-03-17* --- Route: /ReCursor/integrations/claude-code-hooks/ Source: docs-site/src/content/docs/integrations/claude-code-hooks.md Title: Claude Code Hooks Integration Description: Configure Claude Code Hooks to POST events to the ReCursor bridge server for mobile consumption. This is a supported integration path for one-way event observation — not a Remote Control protocol. > Configure Claude Code Hooks to POST events to the ReCursor bridge server for mobile consumption. This is a supported integration path for one-way event observation — not a Remote Control protocol. --- ## Overview Claude Code provides a **Hooks system** that allows plugins to observe and react to events. ReCursor uses this system to receive real-time events from Claude Code, enabling the mobile app to display agent activity with OpenCode-style UI components. > **Important**: Hooks are **one-way observation only**. They cannot inject messages or control the Claude Code session. For bidirectional communication, use the [Agent SDK](../agent-sdk/) for parallel sessions. --- ## Supported Hook Events Based on the Claude Code hooks system source truth, the following events are confirmed: | Event | Trigger | Payload | |-------|---------|---------| | `SessionStart` | New Claude Code session begins | Session metadata | | `SessionEnd` | Session terminates | Session summary | | `PreToolUse` | Agent about to use a tool | Tool, params, risk level | | `PostToolUse` | Tool execution completed | Tool, result, metadata | | `UserPromptSubmit` | User submits a prompt | Prompt text, context | | `Stop` | Agent stops execution | Stop reason, context | | `SubagentStop` | Subagent stops execution | Subagent result, context | | `PreCompact` | Before context compaction | Context stats | | `Notification` | System notification | Message, level | > **Note**: Other events may exist but are not confirmed in the current Claude Code hooks implementation. --- ## Hook Configuration Hooks are configured via a `hooks.json` file in your Claude Code plugin directory. Claude Code supports two hook types: - **`type: "command"`** — Execute bash commands for deterministic checks - **`type: "prompt"`** — Use LLM-driven decision making for context-aware validation ### Method 1: Command Hooks (Recommended for ReCursor) Create a `hooks.json` file in your plugin's `hooks/` directory: ```json { "description": "ReCursor bridge integration - forward events to mobile app", "hooks": { "PreToolUse": [ { "hooks": [ { "type": "command", "command": "curl -X POST https://100.78.42.15:3000/hooks/event -H 'Content-Type: application/json' -H 'Authorization: Bearer your-bridge-token' -d @-", "timeout": 10 } ] } ], "PostToolUse": [ { "hooks": [ { "type": "command", "command": "curl -X POST https://100.78.42.15:3000/hooks/event -H 'Content-Type: application/json' -H 'Authorization: Bearer your-bridge-token' -d @-", "timeout": 10 } ] } ], "SessionStart": [ { "hooks": [ { "type": "command", "command": "curl -X POST https://100.78.42.15:3000/hooks/event -H 'Content-Type: application/json' -H 'Authorization: Bearer your-bridge-token' -d @-", "timeout": 10 } ] } ], "SessionEnd": [ { "hooks": [ { "type": "command", "command": "curl -X POST https://100.78.42.15:3000/hooks/event -H 'Content-Type: application/json' -H 'Authorization: Bearer your-bridge-token' -d @-", "timeout": 10 } ] } ], "Stop": [ { "hooks": [ { "type": "command", "command": "curl -X POST https://100.78.42.15:3000/hooks/event -H 'Content-Type: application/json' -H 'Authorization: Bearer your-bridge-token' -d @-", "timeout": 10 } ] } ], "UserPromptSubmit": [ { "hooks": [ { "type": "command", "command": "curl -X POST https://100.78.42.15:3000/hooks/event -H 'Content-Type: application/json' -H 'Authorization: Bearer your-bridge-token' -d @-", "timeout": 10 } ] } ] } } ``` ### Method 2: Prompt-Based Hooks For context-aware validation, use prompt-based hooks: ```json { "description": "Validation hooks with LLM evaluation", "hooks": { "PreToolUse": [ { "matcher": "Write|Edit", "hooks": [ { "type": "prompt", "prompt": "Validate file write safety. Check: system paths, credentials, path traversal, sensitive content. Return 'approve' or 'deny'.", "timeout": 30 } ] } ], "Stop": [ { "hooks": [ { "type": "prompt", "prompt": "Evaluate if the task is truly complete. Check: all requirements met, tests passing, documentation updated. Return 'complete' or 'continue'.", "timeout": 30 } ] } ] } } ``` ### Plugin Directory Structure ``` ~/.claude-code/plugins/ └── recursor-bridge/ ├── hooks.json # Hook definitions └── README.md # Plugin documentation ``` --- ## Bridge Server Endpoint The ReCursor bridge exposes a `/hooks/event` endpoint to receive Claude Code events: ```typescript import express from 'express'; import { EventEmitter } from 'events'; const app = express(); const eventBus = new EventEmitter(); // Middleware app.use(express.json()); // Hook event endpoint app.post('/hooks/event', validateHookToken, (req, res) => { const hookEvent = req.body; if (!validateHookEvent(hookEvent)) { return res.status(400).json({ error: 'Invalid hook event' }); } // Emit for internal handling eventBus.emit('claude-event', hookEvent); // Queue for offline mobile clients eventQueue.enqueue(hookEvent); res.status(200).json({ received: true }); }); // Token validation middleware function validateHookToken(req: Request, res: Response, next: NextFunction) { const authHeader = req.headers['authorization']; const token = authHeader?.replace('Bearer ', ''); if (token !== process.env.HOOK_TOKEN) { return res.status(401).json({ error: 'Unauthorized' }); } next(); } // Event validation function validateHookEvent(event: unknown): event is HookEvent { return ( typeof event === 'object' && event !== null && 'event_type' in event && typeof (event as HookEvent).event_type === 'string' ); } // Broadcast to connected mobile clients function broadcastToMobile(event: HookEvent) { mobileClients.forEach(client => { if (client.sessionId === event.session_id) { client.ws.send(JSON.stringify({ type: 'claude_event', payload: event })); } }); } eventBus.on('claude-event', broadcastToMobile); ``` --- ## Formal Event Schema & Validation Contract This section defines the formal JSON Schema for Claude Code Hook events and the validation contract used by the ReCursor bridge server. > **Source of Truth**: TypeScript types are authoritative. Dart models are derived. ### Base Event Structure All hook events share this base structure: ```typescript // TypeScript source of truth interface HookEvent { event: HookEventType; // Required: Event discriminator timestamp: string; // Required: ISO 8601 UTC session_id: string; // Required: Session identifier payload: HookEventPayload; // Required: Event-specific data } type HookEventType = | 'SessionStart' | 'SessionEnd' | 'PreToolUse' | 'PostToolUse' | 'UserPromptSubmit' | 'Stop' | 'SubagentStop' | 'PreCompact' | 'Notification'; type HookEventPayload = | SessionStartPayload | SessionEndPayload | PreToolUsePayload | PostToolUsePayload | UserPromptSubmitPayload | StopPayload | SubagentStopPayload | PreCompactPayload | NotificationPayload; ``` ### JSON Schema Definition ```json { "$schema": "http://json-schema.org/draft-07/schema#", "$id": "https://recursor.dev/schemas/hook-event.json", "title": "Claude Code Hook Event", "type": "object", "required": ["event", "timestamp", "session_id", "payload"], "properties": { "event": { "type": "string", "enum": [ "SessionStart", "SessionEnd", "PreToolUse", "PostToolUse", "UserPromptSubmit", "Stop", "SubagentStop", "PreCompact", "Notification" ] }, "timestamp": { "type": "string", "format": "date-time" }, "session_id": { "type": "string", "minLength": 1 }, "payload": { "type": "object" } }, "allOf": [ { "if": { "properties": { "event": { "const": "SessionStart" } } }, "then": { "properties": { "payload": { "$ref": "#/definitions/SessionStartPayload" } } } }, { "if": { "properties": { "event": { "const": "PreToolUse" } } }, "then": { "properties": { "payload": { "$ref": "#/definitions/PreToolUsePayload" } } } }, { "if": { "properties": { "event": { "const": "PostToolUse" } } }, "then": { "properties": { "payload": { "$ref": "#/definitions/PostToolUsePayload" } } } }, { "if": { "properties": { "event": { "const": "UserPromptSubmit" } } }, "then": { "properties": { "payload": { "$ref": "#/definitions/UserPromptSubmitPayload" } } } }, { "if": { "properties": { "event": { "const": "Stop" } } }, "then": { "properties": { "payload": { "$ref": "#/definitions/StopPayload" } } } } ], "definitions": { "SessionStartPayload": { "type": "object", "required": ["working_directory"], "properties": { "working_directory": { "type": "string" }, "initial_prompt": { "type": "string" }, "environment": { "type": "object" } } }, "PreToolUsePayload": { "type": "object", "required": ["tool", "params", "risk_level", "requires_approval"], "properties": { "tool": { "type": "string" }, "params": { "type": "object" }, "risk_level": { "type": "string", "enum": ["low", "medium", "high", "critical"] }, "requires_approval": { "type": "boolean" } } }, "PostToolUsePayload": { "type": "object", "required": ["tool", "params", "result", "success"], "properties": { "tool": { "type": "string" }, "params": { "type": "object" }, "result": {}, "success": { "type": "boolean" }, "execution_time_ms": { "type": "number" } } }, "UserPromptSubmitPayload": { "type": "object", "required": ["prompt"], "properties": { "prompt": { "type": "string" }, "context_files": { "type": "array", "items": { "type": "string" } }, "estimated_tokens": { "type": "integer" } } }, "StopPayload": { "type": "object", "required": ["reason"], "properties": { "reason": { "type": "string", "enum": ["task_completed", "user_request", "error", "max_tokens", "safety"] }, "message": { "type": "string" }, "context": { "type": "object" } } } } } ``` ### Validation Contract The bridge server validates all incoming hook events according to these rules: | Field | Requirement | Validation Rule | Error Code | |-------|-------------|-------------------|------------| | `event` | Required | Must be in confirmed events list | `HOOK_INVALID_EVENT_TYPE` | | `timestamp` | Required | ISO 8601 format, within 5 min skew | `HOOK_INVALID_TIMESTAMP` | | `session_id` | Required | Non-empty string, valid format | `HOOK_INVALID_SESSION_ID` | | `payload` | Required | Object matching event schema | `HOOK_INVALID_PAYLOAD` | ### Timestamp Validation Timestamps are validated for freshness and format: ```typescript const MAX_TIMESTAMP_SKEW_MS = 5 * 60 * 1000; // 5 minutes function validateTimestamp(timestamp: string): ValidationResult { // Parse ISO 8601 const parsed = Date.parse(timestamp); if (isNaN(parsed)) { return { valid: false, code: 'HOOK_INVALID_TIMESTAMP', reason: 'Not ISO 8601' }; } const eventTime = new Date(parsed); const now = new Date(); const diff = Math.abs(now.getTime() - eventTime.getTime()); // Reject future/past events outside skew window if (diff > MAX_TIMESTAMP_SKEW_MS) { return { valid: false, code: 'HOOK_STALE_TIMESTAMP', reason: `Event timestamp ${diff}ms from current time` }; } return { valid: true }; } ``` ### TypeScript Validation (Zod) ```typescript import { z } from 'zod'; // Risk level enum const RiskLevelSchema = z.enum(['low', 'medium', 'high', 'critical']); // Base event schema const HookEventSchema = z.object({ event: z.enum([ 'SessionStart', 'SessionEnd', 'PreToolUse', 'PostToolUse', 'UserPromptSubmit', 'Stop', 'SubagentStop', 'PreCompact', 'Notification' ]), timestamp: z.string().datetime(), session_id: z.string().min(1), payload: z.record(z.unknown()), }); // Payload schemas by event type const SessionStartPayloadSchema = z.object({ working_directory: z.string(), initial_prompt: z.string().optional(), environment: z.record(z.unknown()).optional(), }); const PreToolUsePayloadSchema = z.object({ tool: z.string(), params: z.record(z.unknown()), risk_level: RiskLevelSchema, requires_approval: z.boolean(), }); const PostToolUsePayloadSchema = z.object({ tool: z.string(), params: z.record(z.unknown()), result: z.unknown(), success: z.boolean(), execution_time_ms: z.number().optional(), }); // Event type discriminator const EventTypeToPayloadSchema = { SessionStart: SessionStartPayloadSchema, PreToolUse: PreToolUsePayloadSchema, PostToolUse: PostToolUsePayloadSchema, // ... other event types } as const; // Validation function export function validateHookEvent(data: unknown): { success: true; event: HookEvent } | { success: false; errors: ValidationError[] } { // Validate base structure const baseResult = HookEventSchema.safeParse(data); if (!baseResult.success) { return { success: false, errors: baseResult.error.errors.map(e => ({ field: e.path.join('.'), message: e.message, code: 'VALIDATION_ERROR' })) }; } const event = baseResult.data; // Validate timestamp freshness const timestampValidation = validateTimestamp(event.timestamp); if (!timestampValidation.valid) { return { success: false, errors: [{ field: 'timestamp', message: timestampValidation.reason, code: timestampValidation.code }] }; } // Validate payload against event-specific schema const payloadSchema = EventTypeToPayloadSchema[event.event as keyof typeof EventTypeToPayloadSchema]; if (payloadSchema) { const payloadResult = payloadSchema.safeParse(event.payload); if (!payloadResult.success) { return { success: false, errors: payloadResult.error.errors.map(e => ({ field: `payload.${e.path.join('.')}`, message: e.message, code: 'PAYLOAD_VALIDATION_ERROR' })) }; } } return { success: true, event: event as HookEvent }; } ``` ### Dart Validation ```dart import 'package:json_annotation/json_annotation.dart'; // Generated code part 'hook_event.g.dart'; @JsonSerializable() class HookEvent { final String event; final DateTime timestamp; final String sessionId; final Map payload; HookEvent({ required this.event, required this.timestamp, required this.sessionId, required this.payload, }); factory HookEvent.fromJson(Map json) => _$HookEventFromJson(json); Map toJson() => _$HookEventToJson(this); } // Validation class HookEventValidator { static const List validEventTypes = [ 'SessionStart', 'SessionEnd', 'PreToolUse', 'PostToolUse', 'UserPromptSubmit', 'Stop', 'SubagentStop', 'PreCompact', 'Notification' ]; static const Duration maxTimestampSkew = Duration(minutes: 5); static ValidationResult validate(Map json) { final errors = []; // Validate required fields if (!json.containsKey('event')) { errors.add(ValidationError(field: 'event', message: 'Required field missing')); } else if (!validEventTypes.contains(json['event'])) { errors.add(ValidationError( field: 'event', message: 'Invalid event type: ${json['event']}', code: 'HOOK_INVALID_EVENT_TYPE', )); } if (!json.containsKey('timestamp')) { errors.add(ValidationError(field: 'timestamp', message: 'Required field missing')); } else { try { final timestamp = DateTime.parse(json['timestamp'] as String); final now = DateTime.now(); final diff = now.difference(timestamp).abs(); if (diff > maxTimestampSkew) { errors.add(ValidationError( field: 'timestamp', message: 'Timestamp ${diff.inSeconds}s from current time', code: 'HOOK_STALE_TIMESTAMP', )); } } catch (e) { errors.add(ValidationError( field: 'timestamp', message: 'Invalid ISO 8601 format', code: 'HOOK_INVALID_TIMESTAMP', )); } } if (!json.containsKey('session_id')) { errors.add(ValidationError(field: 'session_id', message: 'Required field missing')); } else if ((json['session_id'] as String).isEmpty) { errors.add(ValidationError( field: 'session_id', message: 'Session ID cannot be empty', code: 'HOOK_INVALID_SESSION_ID', )); } if (!json.containsKey('payload')) { errors.add(ValidationError(field: 'payload', message: 'Required field missing')); } else if (json['payload'] is! Map) { errors.add(ValidationError( field: 'payload', message: 'Payload must be an object', code: 'HOOK_INVALID_PAYLOAD', )); } return errors.isEmpty ? ValidationResult.valid() : ValidationResult.invalid(errors); } } class ValidationResult { final bool isValid; final List errors; ValidationResult.valid() : isValid = true, errors = []; ValidationResult.invalid(this.errors) : isValid = false; } class ValidationError { final String field; final String message; final String? code; ValidationError({ required this.field, required this.message, this.code, }); } ``` ### Validation Response Format When validation fails, the bridge server responds with: ```json { "received": false, "validation_errors": [ { "field": "timestamp", "message": "Event timestamp 312000ms from current time", "code": "HOOK_STALE_TIMESTAMP" } ], "timestamp": "2026-03-20T14:32:00.000Z" } ``` --- ## Event Payload Schemas ### PostToolUse Event ```json { "event_type": "PostToolUse", "session_id": "sess-abc123", "timestamp": "2026-03-17T10:32:00Z", "payload": { "tool": "edit_file", "params": { "file_path": "/home/user/project/lib/main.dart", "old_string": "void main() {", "new_string": "void main() async {" }, "result": { "success": true, "diff": "... unified diff ..." }, "metadata": { "token_count": 150, "duration_ms": 250 } } } ``` ### PreToolUse Event ```json { "event_type": "PreToolUse", "session_id": "sess-abc123", "timestamp": "2026-03-17T10:32:00Z", "payload": { "tool": "Bash", "params": { "command": "rm -rf /important", "description": "Clean up files" }, "risk_level": "high", "requires_approval": true } } ``` ### SessionStart Event ```json { "event_type": "SessionStart", "session_id": "sess-abc123", "timestamp": "2026-03-17T10:30:00Z", "payload": { "working_directory": "/home/user/project", "initial_prompt": "Refactor the authentication module", "environment": { "shell": "zsh", "claude_version": "2.1.0" } } } ``` ### SessionEnd Event ```json { "event_type": "SessionEnd", "session_id": "sess-abc123", "timestamp": "2026-03-17T11:45:00Z", "payload": { "duration_seconds": 4500, "tools_used": ["Read", "Edit", "Bash"], "summary": "Completed authentication refactoring", "exit_code": 0 } } ``` ### Stop Event ```json { "event_type": "Stop", "session_id": "sess-abc123", "timestamp": "2026-03-17T11:45:00Z", "payload": { "reason": "task_completed", "message": "Task completed successfully", "context": { "files_modified": 3, "tests_passed": true } } } ``` ### UserPromptSubmit Event ```json { "event_type": "UserPromptSubmit", "session_id": "sess-abc123", "timestamp": "2026-03-17T10:35:00Z", "payload": { "prompt": "Add error handling to the bridge setup reconnect flow", "context": { "current_file": "lib/features/startup/bridge_setup_screen.dart", "cursor_position": 145 } } } ``` --- ## Security Considerations 1. **Token Authentication**: Always use the `Authorization: Bearer ` header 2. **HTTPS Only**: Use TLS for all hook communications in production 3. **IP Allowlisting**: Restrict bridge endpoint to known Claude Code IPs if possible 4. **Payload Validation**: Validate all incoming hook events before processing 5. **Rate Limiting**: Implement rate limiting on the `/hooks/event` endpoint --- ## Troubleshooting ### Hooks Not Firing 1. Verify `hooks.json` syntax is valid JSON 2. Check plugin is in correct directory: `~/.claude-code/plugins/` 3. Ensure hook commands have execute permissions 4. Review Claude Code logs for hook execution errors ### Bridge Not Receiving Events 1. Verify bridge URL is accessible from Claude Code host 2. Check firewall rules allow outbound HTTP to bridge 3. Confirm authentication token matches 4. Test with simple `curl` command manually ### Event Validation Failures 1. Ensure events match expected schema 2. Check `event_type` is in the confirmed events list 3. Verify `timestamp` is ISO 8601 format 4. Confirm `session_id` is present and valid --- ## References - [Claude Code Hook Development Guide](file:///C:/Repository/claude-code/plugins/plugin-dev/skills/hook-development/SKILL.md) - [Hookify Plugin Example](file:///C:/Repository/claude-code/plugins/hookify/hooks/hooks.json) - [Agent SDK Integration](../agent-sdk/) — For bidirectional communication - [Bridge Protocol](../../architecture/bridge-protocol/) — WebSocket message specification --- *Last updated: 2026-03-20 | Verified against Claude Code source truth* --- Route: /ReCursor/integrations/opencode-ui-patterns/ Source: docs-site/src/content/docs/integrations/opencode-ui-patterns.md Title: OpenCode UI Patterns for ReCursor Description: Mapping OpenCode's terminal-native UI components to Flutter mobile widgets. > Mapping OpenCode's terminal-native UI components to Flutter mobile widgets. --- ## Overview **OpenCode** ([opencode-ai/opencode](https://github.com/opencode-ai/opencode)) is a terminal-native AI coding agent with a sophisticated UI for displaying tool use, diffs, and session state. ReCursor adapts these patterns for mobile Flutter. > **Source Reference**: `C:/Repository/opencode/packages/ui/src/components/` --- ## Component Mapping ### Tool Cards OpenCode renders rich tool cards in the terminal. ReCursor adapts these as Flutter cards. #### OpenCode Pattern ```typescript // OpenCode: packages/ui/src/components/basic-tool.tsx interface ToolCardProps { tool: string; params: Record; result?: ToolResult; status: 'pending' | 'running' | 'completed' | 'error'; } // Terminal output with ANSI colors and formatting ``` #### ReCursor Flutter Implementation ```dart // ReCursor: lib/features/chat/widgets/tool_card.dart class ToolCard extends StatelessWidget { final ToolUse tool; final ToolResult? result; final ToolStatus status; @override Widget build(BuildContext context) { return Card( elevation: 2, margin: EdgeInsets.symmetric(vertical: 8, horizontal: 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _ToolHeader(tool: tool, status: status), _ToolParams(params: tool.params), if (result != null) _ToolResult(result: result), ], ), ); } } ``` #### Tool Card States | State | OpenCode | ReCursor | |-------|----------|----------| | Pending | Spinner + "Running..." | `CircularProgressIndicator` + pulse animation | | Running | Live output stream | Streaming text with fade-in | | Completed | Checkmark + result | `Icons.check_circle` + expandable result | | Error | Red X + error details | `Icons.error` + error card | --- ### Diff Viewer OpenCode shows syntax-highlighted diffs. ReCursor provides touch-friendly diff viewing. #### OpenCode Pattern ```typescript // OpenCode: packages/ui/src/components/diff-changes.tsx interface DiffChangesProps { files: DiffFile[]; viewMode: 'unified' | 'split'; } // Terminal diff with ANSI colors {files.map(file => ( ))} ``` #### ReCursor Flutter Implementation ```dart // ReCursor: lib/features/diff/widgets/diff_viewer.dart class DiffViewer extends StatelessWidget { final List files; final DiffViewMode viewMode; @override Widget build(BuildContext context) { return ListView.builder( itemCount: files.length, itemBuilder: (context, index) { return DiffFileCard( file: files[index], viewMode: viewMode, ); }, ); } } class DiffFileCard extends StatelessWidget { final DiffFile file; @override Widget build(BuildContext context) { return ExpansionTile( leading: _FileStatusIcon(status: file.status), title: Text(file.path), subtitle: Text('+${file.additions} -${file.deletions}'), children: [ DiffHunksView(hunks: file.hunks), ], ); } } ``` #### Diff Line Rendering ```dart // Syntax-highlighted diff lines class DiffLine extends StatelessWidget { final DiffLineType type; // added, removed, context final String content; final int? lineNumber; @override Widget build(BuildContext context) { return Container( color: _backgroundColorForType(type), padding: EdgeInsets.symmetric(horizontal: 8, vertical: 2), child: Row( children: [ if (lineNumber != null) Text('$lineNumber', style: TextStyle(color: Colors.grey)), SizedBox(width: 8), _DiffMarker(type: type), Expanded( child: SyntaxHighlightedText( code: content, language: file.extension, ), ), ], ), ); } } ``` --- ### Session Timeline OpenCode shows a timeline of session events. ReCursor adapts this as a scrollable timeline. #### OpenCode Pattern ```typescript // OpenCode: packages/ui/src/components/session-turn.tsx interface SessionTurnProps { turns: Turn[]; currentTurn: number; } // Terminal timeline with turn markers {turns.map((turn, index) => ( ))} ``` #### ReCursor Flutter Implementation ```dart // ReCursor: lib/features/session/widgets/session_timeline.dart class SessionTimeline extends StatelessWidget { final List events; final String? currentEventId; @override Widget build(BuildContext context) { return ListView.builder( itemCount: events.length, itemBuilder: (context, index) { return TimelineTile( event: events[index], isActive: events[index].id == currentEventId, isFirst: index == 0, isLast: index == events.length - 1, ); }, ); } } class TimelineTile extends StatelessWidget { final SessionEvent event; final bool isActive; @override Widget build(BuildContext context) { return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Timeline connector Column( children: [ _TimelineDot( type: event.type, isActive: isActive, ), if (!isLast) _TimelineConnector(), ], ), SizedBox(width: 12), // Event content Expanded( child: _EventCard(event: event), ), ], ); } } ``` --- ### Message Parts OpenCode renders message content as typed parts. ReCursor uses similar part-based rendering. #### OpenCode Pattern ```typescript // OpenCode: packages/ui/src/components/message-part.tsx interface MessagePartProps { part: MessagePart; } type MessagePart = | { type: 'text'; content: string } | { type: 'tool_use'; tool: string; params: any } | { type: 'tool_result'; result: ToolResult } | { type: 'thinking'; content: string }; // Render based on part type function MessagePart({ part }: MessagePartProps) { switch (part.type) { case 'text': return ; case 'tool_use': return ; case 'tool_result': return ; case 'thinking': return ; } } ``` #### ReCursor Flutter Implementation ```dart // ReCursor: lib/features/chat/widgets/message_part.dart class MessagePart extends StatelessWidget { final MessagePartEntity part; @override Widget build(BuildContext context) { return part.map( text: (p) => MarkdownText(content: p.content), toolUse: (p) => ToolCard( tool: p.tool, params: p.params, status: ToolStatus.pending, ), toolResult: (p) => ToolResultCard(result: p.result), thinking: (p) => ThinkingBlock(content: p.content), ); } } // Freezed union for type-safe message parts @freezed class MessagePartEntity with _$MessagePartEntity { const factory MessagePartEntity.text({ required String content, }) = TextPart; const factory MessagePartEntity.toolUse({ required String tool, required Map params, }) = ToolUsePart; const factory MessagePartEntity.toolResult({ required ToolResult result, }) = ToolResultPart; const factory MessagePartEntity.thinking({ required String content, }) = ThinkingPart; } ``` --- ## UI Component Library ### Core Components | OpenCode Component | ReCursor Widget | File | |-------------------|-----------------|------| | `BasicTool` | `ToolCard` | `lib/features/chat/widgets/tool_card.dart` | | `DiffChanges` | `DiffViewer` | `lib/features/diff/widgets/diff_viewer.dart` | | `SessionTurn` | `SessionTimeline` | `lib/features/session/widgets/session_timeline.dart` | | `MessagePart` | `MessagePart` | `lib/features/chat/widgets/message_part.dart` | | `ChatMessage` | `MessageBubble` | `lib/features/chat/widgets/message_bubble.dart` | ### Supporting Widgets ```dart // lib/shared/widgets/ // Tool icon based on tool name class ToolIcon extends StatelessWidget { final String tool; IconData get icon { return switch (tool) { 'edit_file' => Icons.edit, 'read_file' => Icons.file_open, 'run_command' => Icons.terminal, 'glob' => Icons.folder, 'grep' => Icons.search, _ => Icons.build, }; } } // Status indicator for tool execution class ToolStatusIndicator extends StatelessWidget { final ToolStatus status; @override Widget build(BuildContext context) { return switch (status) { ToolStatus.pending => SpinKitPulse(color: Colors.blue), ToolStatus.running => SpinKitWave(color: Colors.orange), ToolStatus.completed => Icon(Icons.check_circle, color: Colors.green), ToolStatus.error => Icon(Icons.error, color: Colors.red), }; } } // Expandable code block with syntax highlighting class CodeBlock extends StatelessWidget { final String code; final String? language; @override Widget build(BuildContext context) { return ExpandablePanel( header: Text(language ?? 'Code'), collapsed: _TruncatedCode(code: code), expanded: SyntaxHighlightedCode( code: code, language: language, ), ); } } ``` --- ## Theming ### OpenCode Color Scheme OpenCode uses a terminal-inspired color scheme: | Element | OpenCode (Terminal) | ReCursor (Flutter) | |---------|---------------------|-------------------| | Background | `#1e1e1e` (dark) | `Color(0xFF1E1E1E)` | | Text | `#d4d4d4` | `Color(0xFFD4D4D4)` | | Added lines | `#4ec9b0` (green) | `Colors.green[400]` | | Removed lines | `#f44747` (red) | `Colors.red[400]` | | Tool header | `#569cd6` (blue) | `Colors.blue[400]` | | Accent | `#ce9178` (orange) | `Colors.orange[300]` | ### Material You Adaptation ```dart // lib/core/theme/app_theme.dart class AppTheme { static ThemeData get darkTheme { return ThemeData.dark().copyWith( colorScheme: ColorScheme.dark( primary: Color(0xFF569CD6), secondary: Color(0xFF4EC9B0), surface: Color(0xFF1E1E1E), background: Color(0xFF121212), error: Color(0xFFF44747), ), cardTheme: CardTheme( color: Color(0xFF252526), elevation: 2, ), textTheme: TextTheme( bodyMedium: TextStyle( color: Color(0xFFD4D4D4), fontFamily: 'JetBrainsMono', ), ), ); } } ``` --- ## Responsive Considerations ### Mobile Adaptations | OpenCode (Terminal) | ReCursor (Mobile) | |---------------------|-------------------| | Fixed-width font | Dynamic font sizing | | Horizontal scrolling | Horizontal swipe gestures | | Keyboard shortcuts | Touch gestures + FABs | | Split panes | Tab navigation | | Mouse hover | Long-press menus | ### Tablet Layouts ```dart // lib/features/chat/screens/chat_screen.dart class ChatScreen extends StatelessWidget { @override Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { if (constraints.maxWidth > 600) { // Tablet: Split view return Row( children: [ Expanded(flex: 2, child: ChatPanel()), Expanded(flex: 3, child: DetailPanel()), ], ); } // Phone: Single panel return ChatPanel(); }, ); } } ``` --- ## Animation Patterns ### Tool Card Animations ```dart // Smooth expansion when tool completes class ToolCard extends StatefulWidget { @override _ToolCardState createState() => _ToolCardState(); } class _ToolCardState extends State with SingleTickerProviderStateMixin { late AnimationController _controller; late Animation _expandAnimation; @override void initState() { super.initState(); _controller = AnimationController( duration: Duration(milliseconds: 300), vsync: this, ); _expandAnimation = CurvedAnimation( parent: _controller, curve: Curves.easeInOut, ); } @override void didUpdateWidget(ToolCard oldWidget) { super.didUpdateWidget(oldWidget); if (widget.status == ToolStatus.completed && oldWidget.status != ToolStatus.completed) { _controller.forward(); } } @override Widget build(BuildContext context) { return SizeTransition( sizeFactor: _expandAnimation, child: Card(/* ... */), ); } } ``` --- ## Related Documentation - [Architecture Overview](../../architecture/system-overview/) — System architecture - [Data Flow](../../architecture/data-flow/) — Message sequence diagrams - [Claude Code Hooks Integration](../claude-code-hooks/) — Event source - [Agent SDK Integration](../agent-sdk/) — Session control --- *Last updated: 2026-03-17* --- Route: /ReCursor/operations/ci-cd/ Source: docs-site/src/content/docs/operations/ci-cd.md Title: CI/CD Pipeline Description: GitHub Actions + Fastlane for building, testing, and distributing ReCursor to iOS and Android. > GitHub Actions + Fastlane for building, testing, and distributing ReCursor to iOS and Android. --- ## Pipeline Overview ``` PR opened/updated: [flutter analyze] -> [flutter test] -> (pass/fail) Push to main: [flutter analyze] -> [flutter test] -> [build Android APK/AAB] -> [build iOS IPA] | | Play Store TestFlight (internal track) ``` --- ## GitHub Actions Workflow Structure Three jobs: 1. **`test`** — runs on `ubuntu-latest`: analyze, unit tests, widget tests, golden tests 2. **`build-android`** — runs on `ubuntu-latest`: build AAB, upload to Play Store internal track 3. **`build-ios`** — runs on `macos-latest` (required for Xcode): build IPA, upload to TestFlight --- ## Workflow Configuration ```yaml # .github/workflows/test.yml name: Test on: pull_request: branches: [main] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: subosito/flutter-action@v2 with: flutter-version: '3.24.0' channel: 'stable' - name: Install dependencies run: flutter pub get working-directory: apps/mobile - name: Analyze run: flutter analyze working-directory: apps/mobile - name: Run tests run: flutter test working-directory: apps/mobile ``` ```yaml # .github/workflows/deploy.yml name: Deploy on: push: branches: [main] jobs: test: runs-on: ubuntu-latest steps: # Same as test.yml build-android: needs: test runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: subosito/flutter-action@v2 with: flutter-version: '3.24.0' - name: Setup Fastlane uses: ruby/setup-ruby@v1 with: ruby-version: '3.2' bundler-cache: true - name: Decode keystore run: | echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > android/app/keystore.jks working-directory: apps/mobile - name: Build AAB run: fastlane android deploy working-directory: apps/mobile env: KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} build-ios: needs: test runs-on: macos-latest steps: - uses: actions/checkout@v4 - uses: subosito/flutter-action@v2 with: flutter-version: '3.24.0' - name: Setup Fastlane uses: ruby/setup-ruby@v1 with: ruby-version: '3.2' bundler-cache: true - name: Build IPA run: fastlane ios deploy working-directory: apps/mobile env: MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} FASTLANE_PASSWORD: ${{ secrets.FASTLANE_PASSWORD }} ``` --- ## iOS Code Signing (Fastlane Match) - Store encrypted certificates and provisioning profiles in a **private Git repo**. - Required GitHub Secrets: - `MATCH_GIT_BASIC_AUTHORIZATION` — base64-encoded `username:PAT` - `MATCH_PASSWORD` — encryption passphrase - App Store Connect API key (preferred over `FASTLANE_PASSWORD` to avoid 2FA issues) - Use `setup_ci` in the Fastlane lane to create a temporary keychain on the CI runner. ### Fastfile (iOS) ```ruby # fastlane/Fastfile platform :ios do desc "Deploy iOS app to TestFlight" lane :deploy do setup_ci match( type: "appstore", readonly: is_ci, ) build_app( scheme: "Runner", workspace: "ios/Runner.xcworkspace", ) upload_to_testflight( skip_waiting_for_build_processing: true, ) end end ``` --- ## Android Code Signing - Store the keystore as a **base64-encoded GitHub Secret** (`KEYSTORE_BASE64`). - Decode in workflow: `echo $KEYSTORE_BASE64 | base64 --decode > android/app/keystore.jks` - Reference key alias and passwords from secrets in `key.properties`. - Upload to Play Store internal track via Fastlane's `supply` action. - **Note:** First release must be uploaded manually via Play Console. ### Fastfile (Android) ```ruby # fastlane/Fastfile platform :android do desc "Deploy Android app to Play Store" lane :deploy do build_android_app( task: "bundle", build_type: "release", ) upload_to_play_store( track: "internal", release_status: "draft", ) end end ``` --- ## Key Principles - **Never echo secret values in logs.** - PR builds run tests only, never deploy. - Use branch-based triggers: PRs -> test; `main` push -> test + deploy. - Pin Flutter version in CI to match local development (`subosito/flutter-action`). - Cache pub dependencies and build artifacts between runs. --- ## Alternative: Codemagic - Purpose-built for Flutter with macOS build machines included. - Built-in code signing management (no Fastlane config needed). - Costs money but saves significant setup time, especially for iOS. - Consider if GitHub Actions macOS runner costs or complexity become prohibitive. --- ## Related Documentation - [Testing Strategy](../testing-strategy/) — Testing approach - [Architecture Overview](../../architecture/system-overview/) — System architecture --- *Last updated: 2026-03-17* --- Route: /ReCursor/operations/offline-architecture/ Source: docs-site/src/content/docs/operations/offline-architecture.md Title: Offline-First Architecture Description: How the ReCursor app works without connectivity and syncs when reconnected. > How the ReCursor app works without connectivity and syncs when reconnected. --- ## Storage Strategy | Data Type | Storage | Rationale | |-----------|---------|-----------| | Conversations, tasks, agent configs | **Drift** (SQLite) | Type-safe queries, migrations, reactive streams, relational integrity | | UI preferences, cached tokens, session state | **Hive** | Fast key-value for ephemeral data | | File content cache | **File system** | Large blobs don't belong in SQLite | --- ## Repository Pattern ``` UI Layer (Riverpod providers) | Repository Layer (abstracts local vs. remote) | +-- Local Data Source (Drift / Hive) +-- Remote Data Source (Bridge WebSocket) ``` - Repository reads from local DB first (instant UI response). - Fetches from bridge in background and updates local state. - Drift's reactive queries (`watch()`) automatically update the UI when local data changes. --- ## Sync Queue When offline, mutations go into a local queue: ```dart // SyncQueue table (Drift) class SyncQueue extends Table { IntColumn get id => integer().autoIncrement()(); TextColumn get operation => text()(); // "send_message", "approve_tool", "git_command" TextColumn get payload => text()(); // JSON: full operation TextColumn get sessionId => text().nullable()(); DateTimeColumn get createdAt => dateTime()(); BoolColumn get synced => boolean().withDefault(const Constant(false))(); IntColumn get retryCount => integer().withDefault(const Constant(0))(); TextColumn get lastError => text().nullable()(); } ``` ### Queue Operations ```dart class SyncService { final SyncQueueDao _queueDao; final WebSocketService _ws; // Enqueue mutation when offline Future enqueue(String operation, Map payload) async { await _queueDao.insert(SyncQueueCompanion( operation: Value(operation), payload: Value(jsonEncode(payload)), createdAt: Value(DateTime.now()), )); } // Flush queue on reconnect Future flushQueue() async { final pending = await _queueDao.getPending(); for (final item in pending) { try { await _sendToBridge(item); await _queueDao.markSynced(item.id); } catch (e) { await _queueDao.incrementRetry(item.id, e.toString()); } } } } ``` --- ## Conflict Resolution ### Default: Last-Write-Wins ```dart class ConflictResolver { T resolve(T local, T remote) { // Compare updated_at timestamps if (local.updatedAt.isAfter(remote.updatedAt)) { return local; // Local wins } return remote; // Remote wins } } ``` ### Critical Operations For destructive operations (git push, file overwrite), prompt user: ```dart Future resolveCriticalConflict({ required SyncConflict conflict, }) async { // Show dialog to user return showDialog( context: context, builder: (_) => ConflictDialog(conflict: conflict), ); } enum ConflictResolution { useLocal, useRemote, merge, cancel, } ``` --- ## Network Detection ```dart class NetworkService { final Connectivity _connectivity; final WebSocketService _ws; Stream get status { return _connectivity.onConnectivityChanged .asyncMap((result) => _mapToStatus(result)); } Future _mapToStatus(ConnectivityResult result) async { if (result == ConnectivityResult.none) { return ConnectionStatus.offline; } // Ping bridge to confirm reachability final reachable = await _pingBridge(); return reachable ? ConnectionStatus.online : ConnectionStatus.bridg Unreachable; } } ``` ### Connection States | State | Description | Behavior | |-------|-------------|----------| | `online` | Connected to bridge | Sync queue, real-time updates | | `offline` | No connectivity | Queue mutations locally | | `bridge_unreachable` | Network but no bridge | Retry with backoff, queue mutations | --- ## Sync Strategies ### Push-First (Outbound) 1. User action (send message, approve tool) 2. Save to local DB 3. Try to send via WebSocket 4. If failed, add to SyncQueue 5. Show "pending" state in UI ### Pull-First (Inbound) 1. On reconnect, request all events since last sync 2. Merge with local state 3. Resolve conflicts 4. Update UI ### Event Replay ```dart class EventReplay { Future replaySince(DateTime lastSync) async { final events = await _bridge.getEventsSince(lastSync); for (final event in events) { await _applyEvent(event); } } } ``` --- ## Retry Strategy ```dart class RetryPolicy { final int maxRetries = 5; final List backoffDelays = [ Duration(seconds: 1), Duration(seconds: 2), Duration(seconds: 5), Duration(seconds: 10), Duration(seconds: 30), ]; Future withRetry(Future Function() operation, int attempt) async { try { return await operation(); } catch (e) { if (attempt >= maxRetries) rethrow; await Future.delayed(backoffDelays[attempt]); return withRetry(operation, attempt + 1); } } } ``` --- ## Storage Limits | Data Type | Max Size | Cleanup Strategy | |-----------|----------|------------------| | SyncQueue | 1000 items | FIFO eviction | | Messages | 30 days | Archive to file | | Sessions | 90 days | Soft delete | | File cache | 100 MB | LRU eviction | --- ## Future Scaling If sync complexity grows, consider: - **PowerSync** — integrates with Drift, handles bidirectional sync automatically - **Couchbase Lite** — built-in conflict resolution - Both have commercial licensing but eliminate custom sync engine maintenance. --- ## Related Documentation - [Data Models](../../architecture/data-models/) — Drift schemas - [Architecture Overview](../../architecture/system-overview/) — System architecture - [Bridge Protocol](../../architecture/bridge-protocol/) — WebSocket specification --- *Last updated: 2026-03-17* --- Route: /ReCursor/operations/push-notifications/ Source: docs-site/src/content/docs/operations/push-notifications.md Title: In-App Notification Architecture Description: How the ReCursor bridge server notifies the mobile app about agent events — no Firebase, fully WebSocket-based. > How the ReCursor bridge server notifies the mobile app about agent events — no Firebase, fully WebSocket-based. --- ## Overview ``` Agent Event -> Bridge Server -> WebSocket -> Mobile App -> Local Notification (if backgrounded) ``` All notifications flow through the existing WebSocket connection. No external push services (FCM/APNs) are used. --- ## Notification Delivery ### When App is Connected (Foreground) - Bridge sends a `notification` message over the WebSocket. - App displays an in-app banner/toast or updates the notification center badge. - No OS-level notification needed — the user is already in the app. ### When App is Backgrounded - If the WebSocket connection is still alive (kept by OS background mode), the app receives the event and displays a **local notification** via `flutter_local_notifications`. - Local notifications support action buttons (e.g., "Approve" / "View"). ### When App is Disconnected - Bridge stores events in a **pending event queue**. - On reconnect, bridge replays all unacknowledged events. - App processes the backlog and shows relevant notifications. --- ## Notification Types | Type | Trigger | Priority | Action | |------|---------|----------|--------| | Task Complete | Agent finishes a task | Normal | Navigate to result | | Approval Required | Agent needs tool call approval | High | Approve/reject buttons | | Error | Agent encounters an error | High | Navigate to chat | | Agent Idle | Agent waiting for input | Low | Navigate to chat | --- ## WebSocket Message Format ```json { "type": "notification", "id": "notif-001", "payload": { "session_id": "sess-abc123", "notification_type": "approval_required", "title": "Approval needed: Update bridge_setup_screen.dart", "body": "Claude Code wants to tighten bridge URL validation before pairing.", "priority": "high", "data": { "tool_call_id": "tool-001", "screen": "approval_detail" } } } ``` --- ## Acknowledgment ```json { "type": "notification_ack", "payload": { "notification_ids": ["notif-001", "notif-002"] } } ``` The bridge removes acknowledged events from the pending queue. --- ## In-App Notification Center - Bell icon in the app bar with unread count badge. - Tap to open notification list (grouped by session). - Each notification is tappable — routes to the relevant screen. - "Mark all read" action. - Notifications persist locally in Drift for offline access. --- ## Reliability - WebSocket heartbeat ensures connection is alive. - If heartbeat fails, app shows "disconnected" banner — user knows notifications won't arrive. - On reconnect, bridge replays full event backlog (bounded by configurable max age, e.g., 24 hours). - No silent failure — if the connection is down, the user sees it immediately. --- ## Trade-offs vs. Firebase | | WebSocket-only | Firebase FCM | |---|---|---| | Works when app is killed | No | Yes | | Requires Google/Apple services | No | Yes | | Privacy | Full control | Data via Google servers | | Complexity | Simpler, no external setup | FCM config, APNs certs | | Reliability when connected | Immediate, guaranteed | Best-effort delivery | The WebSocket-only approach is chosen because the app's primary value requires an active bridge connection anyway. If the bridge is unreachable, notifications are moot. --- ## Local Notification Configuration ```dart // Initialize local notifications final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher'); const iosSettings = DarwinInitializationSettings( requestAlertPermission: true, requestBadgePermission: true, requestSoundPermission: true, ); await flutterLocalNotificationsPlugin.initialize( const InitializationSettings(android: androidSettings, iOS: iosSettings), onDidReceiveNotificationResponse: (response) { // Handle notification tap _handleNotificationTap(response.payload); }, ); // Show local notification Future showLocalNotification(AppNotification notification) async { const androidDetails = AndroidNotificationDetails( 'recursor_channel', 'ReCursor Notifications', importance: Importance.high, priority: Priority.high, showWhen: true, ); const iosDetails = DarwinNotificationDetails( presentAlert: true, presentBadge: true, presentSound: true, ); await flutterLocalNotificationsPlugin.show( notification.id.hashCode, notification.title, notification.body, const NotificationDetails(android: androidDetails, iOS: iosDetails), payload: jsonEncode(notification.data), ); } ``` --- ## Related Documentation - [Bridge Protocol](../../architecture/bridge-protocol/) — WebSocket message specification - [Architecture Overview](../../architecture/system-overview/) — System architecture - [Data Flow](../../architecture/data-flow/) — Message sequence diagrams --- *Last updated: 2026-03-17* --- Route: /ReCursor/operations/security-architecture/ Source: docs-site/src/content/docs/operations/security-architecture.md Title: Security Architecture Description: Best practices for securing the WebSocket bridge between the ReCursor mobile app and the coding agent. > Best practices for securing the WebSocket bridge between the ReCursor mobile app and the coding agent. --- ## Network Layer - **Use a secure tunnel for remote access.** Tailscale (recommended) wraps WireGuard encryption, handles NAT traversal, and creates a zero-config mesh VPN between phone and dev machine. DERP relay servers never see unencrypted data. Other options include WireGuard, Cloudflare Tunnel, or SSH tunneling. - **Always use `wss://` (WebSocket Secure).** TLS at the application layer + tunnel encryption at the network layer = defense in depth. - **Never expose the bridge on a public IP without tunnel protection.** The bridge should only be reachable within your secure tunnel network. ## Connection Mode Security ReCursor detects and categorizes bridge connections into five modes, each with distinct security boundaries and user experience: | Mode | Trust Boundary | Risk Level | Mitigation | |------|---------------|------------|------------| | **Local-only** | Same device (loopback) | Minimal | Standard TLS + token auth | | **Private network** | LAN/VPN (RFC1918) | Low | Network isolation + TLS + token | | **Secure remote** | Tailscale/WireGuard mesh | Low | WireGuard encryption + TLS + token | | **Direct public remote** | Public internet | High | Certificate pinning, user acknowledgment, optional IP allowlist | | **Misconfigured** | Insecure transport | Critical | **Connection blocked** | ### Mode Detection Logic The bridge analyzes the incoming connection to determine the mode: ```typescript function detectConnectionMode(remoteAddress: string, protocol: string): ConnectionMode { // Block insecure transport if (protocol === 'ws:') return 'misconfigured'; // Local-only if (isLoopback(remoteAddress)) return 'local_only'; // Private network if (isPrivateIP(remoteAddress)) return 'private_network'; // Secure remote (Tailscale/WireGuard) if (isTailscaleIP(remoteAddress) || isWireGuardIP(remoteAddress)) return 'secure_remote'; // Default to direct public with warning return 'direct_public'; } ``` ### Health Verification Protocol After WebSocket authentication, the client must complete health verification before accessing the main shell: ```mermaid sequenceDiagram participant Mobile as ReCursor App participant Bridge as Bridge Server Mobile->>Bridge: wss:// connect + device_token Bridge-->>Mobile: connection_ack { mode: "detected" } Note over Mobile,Bridge: Health Verification Mobile->>Bridge: health_check { timestamp, client_nonce } Bridge->>Bridge: Verify clock sync, cert validity Bridge-->>Mobile: health_status { mode, warnings, ready } alt mode === "direct_public" Mobile->>Mobile: Show security warning screen Mobile->>Bridge: acknowledge_warning { confirmed: true } Bridge-->>Mobile: acknowledgment_accepted end alt mode === "misconfigured" Bridge-->>Mobile: connection_error { code: "INSECURE_TRANSPORT" } Mobile->>Mobile: Show misconfiguration error end ``` ### Security Warnings for Direct Public Remote When connecting directly to a public IP without a tunnel: 1. **Certificate validation** is mandatory (no self-signed certs) 2. **User acknowledgment** is required before proceeding 3. **Visual indicator** shows warning state (yellow triangle) 4. **Optional**: Bridge can require IP allowlist for additional protection ```typescript // Bridge configuration for public access const PUBLIC_ACCESS_CONFIG = { requireCertificatePinning: true, requireUserAcknowledgment: true, allowedIPs: ['203.0.113.0/24'], // Optional allowlist maxSessionDuration: '8h', // Optional session limits }; ``` ### Misconfigured Mode Blocking Connections are **rejected** (not just warned) for: - `ws://` instead of `wss://` (unencrypted transport) - Missing or invalid TLS certificate - Token transmitted over insecure channel ```json { "type": "connection_error", "payload": { "code": "INSECURE_TRANSPORT", "message": "Bridge requires wss:// (WebSocket Secure). Unencrypted ws:// connections are blocked.", "documentation_url": "https://docs.recursor.dev/security/tls-required" } } ``` --- ## Bridge Connection Security ```mermaid sequenceDiagram participant Mobile as ReCursor App participant Bridge as Bridge Server participant Hooks as Claude Code Hooks Note over Mobile,Hooks: Connection Pairing Mobile->>Bridge: wss:// connect + device_token Bridge->>Bridge: Validate device_token Bridge-->>Mobile: connection_ack Note over Mobile,Hooks: Hook Event Authentication Hooks->>Bridge: HTTP POST + hook_token Bridge->>Bridge: Validate hook_token Bridge-->>Hooks: 200 OK ``` ### Token Types | Token Type | Purpose | Storage | |------------|---------|---------| | Device Pairing Token | Authenticate mobile app to bridge (generated at pairing) | `flutter_secure_storage` | | Hook Token | Authenticate Claude Code Hooks to bridge | Bridge server env only | --- ## Token Management ### Device Pairing Token - **Generate**: 32+ character random string (crypto-safe), generated during bridge setup - **Storage**: Bridge server environment variable or config file - **Mobile**: Encrypted with `flutter_secure_storage` (Keychain/EncryptedSharedPreferences) - **QR Code**: Bridge URL + token encoded for easy pairing - **No User Accounts**: Tokens are per-device, not tied to any user identity or hosted account - **Bridge-First**: No login flow — the app opens to bridge pairing/restore, not sign-in ```dart // Token generation (bridge server) import crypto from 'crypto'; const token = crypto.randomBytes(32).toString('hex'); // 64 chars ``` ### Token Rotation - Rotate device tokens if bridge is reinstalled or security concern arises - Clear token from mobile app via "Disconnect Bridge" in Settings - Support token revocation list on bridge server --- ## Certificate Pinning Flutter supports SSL pinning via `SecurityContext` with certificate chain files from assets. ```dart // Certificate pinning setup Future getSecureContext() async { final context = SecurityContext(withTrustedRoots: false); // Load pinned certificate final cert = await rootBundle.load('assets/certs/bridge.crt'); context.setTrustedCertificatesBytes(cert.buffer.asUint8List()); return context; } // Use with WebSocket final channel = IOWebSocketChannel.connect( 'wss://100.78.42.15:3000', customClient: HttpClient(context: await getSecureContext()), ); ``` **Pin the public key**, not the certificate itself — more resilient to cert renewals. Maintain backup pins per OWASP guidance to prevent app breakage. --- ## Bridge Authorization The bridge server is the security boundary — it must enforce its own authorization layer. - **Allowlist of permitted operations** (e.g., file read yes, `rm -rf /` no) - **Enforce working directory boundaries** — the agent should only access the project directory - **Log all commands** sent through the bridge for audit - **Separate bridge auth from agent auth** — compromising one shouldn't compromise the other ### Working Directory Isolation ```typescript // Bridge server authorization function validateWorkingDirectory( requestedPath: string, allowedRoot: string ): boolean { const resolved = path.resolve(requestedPath); const root = path.resolve(allowedRoot); return resolved.startsWith(root); } ``` ### Operation Allowlist ```typescript const ALLOWED_TOOLS = [ 'read_file', 'edit_file', 'glob', 'grep', 'ls', ]; const BLOCKED_COMMANDS = [ 'rm -rf /', 'sudo', 'chmod 777', ]; ``` --- ## Data in Transit - All WebSocket messages should be JSON with a defined schema - Sensitive data (tokens, keys found in code) should be flagged and optionally redacted in transit - Consider message signing (HMAC) for critical operations (git push, file delete) as an additional integrity check ### Payload Sanitization ```typescript // Redact sensitive patterns from responses function sanitizePayload(payload: unknown): unknown { const sensitivePatterns = [ /[a-zA-Z0-9_-]{20,}\.[_a-zA-Z0-9]{10,}/g, // API keys /ghp_[a-zA-Z0-9]{36}/g, // GitHub tokens /sk-[a-zA-Z0-9]{48}/g, // Anthropic keys ]; const json = JSON.stringify(payload); let sanitized = json; for (const pattern of sensitivePatterns) { sanitized = sanitized.replace(pattern, '[REDACTED]'); } return JSON.parse(sanitized); } ``` --- ## Claude Code Hooks Security ### Hook Endpoint Authentication ```typescript // Bridge server hook endpoint app.post('/hooks/event', (req, res) => { const token = req.headers.authorization?.replace('Bearer ', ''); if (!verifyHookToken(token)) { return res.status(401).json({ error: 'Unauthorized' }); } // Process event }); ``` ### Event Validation ```typescript function validateHookEvent(event: unknown): boolean { // Validate structure // Validate timestamp (not too old) // Validate signature (if using HMAC) return true; } ``` --- ## Security Checklist ### Development - [ ] Never commit secrets to repository - [ ] Use `.env` files for local configuration (not committed) - [ ] Run `flutter analyze` security lints - [ ] Use Snyk or similar for dependency scanning ### Deployment - [ ] Generate unique bridge auth tokens per user/device - [ ] Enable TLS 1.3 on bridge server - [ ] Configure Tailscale ACLs (access control lists) - [ ] Set up intrusion detection on bridge server - [ ] Enable audit logging ### Mobile App - [ ] Use `flutter_secure_storage` for all tokens - [ ] Implement certificate pinning - [ ] Support biometric unlock for sensitive operations - [ ] Clear sensitive data on app background --- ## Threat Model | Threat | Likelihood | Impact | Mitigation | |--------|------------|--------|------------| | Token theft | Medium | High | Secure storage, rotation, short expiry | | Man-in-the-middle | Low | High | TLS + certificate pinning | | Bridge compromise | Low | Critical | Working directory isolation, operation allowlist | | Replay attacks | Low | Medium | Timestamp validation, nonce | | Social engineering | Medium | Medium | Out-of-band confirmation for destructive ops | | Connection mode spoofing | Low | High | Server-side detection, client verification of mode | | DNS hijacking (public remote) | Medium | High | Certificate pinning, DNSSEC where available | | Downgrade to ws:// | Low | Critical | **Block all ws:// connections**, HSTS-like enforcement | --- ## TLS/Certificate Trust Implementation This section provides concrete implementation guidance for TLS certificate management in ReCursor, grounded in patterns from remote-claude and code-server benchmarks. ### Self-Signed Certificate Generation For private networks (Tailscale, WireGuard, LAN), self-signed certificates are acceptable and often necessary. The bridge server can auto-generate certificates on first startup. #### Certificate Generation Script ```bash #!/bin/bash # scripts/generate-cert.sh CERT_DIR="${CERT_DIR:-./certs}" mkdir -p "$CERT_DIR" # Detect Tailscale IP for SAN TAILSCALE_IP=$(tailscale ip -4 2>/dev/null || echo "") HOSTNAME=$(hostname) # Build subjectAltName SAN="DNS:localhost,DNS:$HOSTNAME,IP:127.0.0.1,IP:::1" [ -n "$TAILSCALE_IP" ] && SAN="$SAN,IP:$TAILSCALE_IP" # Generate private key and certificate openssl req -x509 -newkey rsa:2048 \ -keyout "$CERT_DIR/key.pem" \ -out "$CERT_DIR/cert.pem" \ -days 365 \ -nodes \ -subj "/CN=recursor-bridge/O=ReCursor" \ -addext "subjectAltName=$SAN" echo "Certificate generated:" echo " Certificate: $CERT_DIR/cert.pem" echo " Key: $CERT_DIR/key.pem" [ -n "$TAILSCALE_IP" ] && echo " Tailscale IP: $TAILSCALE_IP" ``` #### Programmatic Generation (Node.js) ```typescript import * as forge from 'node-forge'; import * as fs from 'fs'; import * as path from 'path'; import { networkInterfaces } from 'os'; interface CertificateConfig { certDir: string; validityDays: number; keySize: number; } interface GeneratedCertificate { certPath: string; keyPath: string; fingerprint: string; expiresAt: Date; } export async function generateSelfSignedCertificate( config: CertificateConfig ): Promise { const { certDir, validityDays, keySize } = config; // Ensure cert directory exists await fs.promises.mkdir(certDir, { recursive: true }); // Generate key pair const { privateKey, publicKey } = forge.pki.rsa.generateKeyPair(keySize); // Create certificate const cert = forge.pki.createCertificate(); cert.publicKey = publicKey; cert.serialNumber = '01'; cert.validity.notBefore = new Date(); cert.validity.notAfter = new Date(); cert.validity.notAfter.setDate(cert.validity.notBefore.getDate() + validityDays); // Subject attributes const attrs = [ { name: 'commonName', value: 'recursor-bridge' }, { name: 'organizationName', value: 'ReCursor' }, ]; cert.setSubject(attrs); cert.setIssuer(attrs); // Subject Alternative Names const sanIPs = getLocalIPs(); const extensions = [ { name: 'subjectAltName', altNames: [ { type: 2, value: 'localhost' }, { type: 7, ip: '127.0.0.1' }, { type: 7, ip: '::1' }, ...sanIPs.map(ip => ({ type: 7, ip })), ], }, { name: 'keyUsage', keyCertSign: true, digitalSignature: true, nonRepudiation: true, keyEncipherment: true, dataEncipherment: true, }, { name: 'extKeyUsage', serverAuth: true, }, ]; cert.setExtensions(extensions); // Self-sign cert.sign(privateKey, forge.md.sha256.create()); // Convert to PEM const certPem = forge.pki.certificateToPem(cert); const keyPem = forge.pki.privateKeyToPem(privateKey); // Write to files const certPath = path.join(certDir, 'cert.pem'); const keyPath = path.join(certDir, 'key.pem'); await fs.promises.writeFile(certPath, certPem); await fs.promises.writeFile(keyPath, keyPem, { mode: 0o600 }); // Restrict permissions // Calculate fingerprint const fingerprint = forge.md.sha1 .create() .update(forge.asn1.toDer(forge.pki.certificateToAsn1(cert)).getBytes()) .digest() .toHex() .match(/.{2}/g)! .join(':') .toUpperCase(); return { certPath, keyPath, fingerprint, expiresAt: cert.validity.notAfter, }; } function getLocalIPs(): string[] { const interfaces = networkInterfaces(); const ips: string[] = []; for (const name of Object.keys(interfaces)) { for (const iface of interfaces[name] || []) { // Include internal IPs for Tailscale/WireGuard if (iface.family === 'IPv4' && !iface.internal) { ips.push(iface.address); } // Include Tailscale IPs (100.x.x.x range) if (iface.address.startsWith('100.')) { ips.push(iface.address); } } } return [...new Set(ips)]; } ``` ### Certificate Pinning Hash Generate a pinning hash for mobile app inclusion: ```typescript import * as crypto from 'crypto'; import * as fs from 'fs'; export function getCertificatePinningHash(certPath: string): string { const certPem = fs.readFileSync(certPath, 'utf-8'); const cert = certPem .replace(/-----BEGIN CERTIFICATE-----\n/, '') .replace(/\n-----END CERTIFICATE-----/, '') .replace(/\n/g, ''); const certBuffer = Buffer.from(cert, 'base64'); const hash = crypto.createHash('sha256').update(certBuffer).digest('base64'); return `sha256/${hash}`; } // Usage const pinHash = getCertificatePinningHash('./certs/cert.pem'); console.log(`Pinning hash: ${pinHash}`); ``` ### Mobile Platform TLS Caveats #### iOS Specifics ```dart import 'dart:io'; import 'package:flutter/services.dart'; class IOSCertificateTrust { /// iOS requires ATS (App Transport Security) exceptions for self-signed certs /// /// Add to ios/Runner/Info.plist: /// NSAppTransportSecurity /// /// NSExceptionDomains /// /// 100.x.x.x /// /// NSExceptionAllowsInsecureHTTPLoads /// /// NSExceptionMinimumTLSVersion /// TLSv1.3 /// NSTemporaryExceptionAllowsInsecureHTTPLoads /// /// /// /// static Future getSecureContext() async { final context = SecurityContext(withTrustedRoots: true); // Load pinned certificate final certData = await rootBundle.load('assets/certs/bridge.crt'); context.setTrustedCertificatesBytes(certData.buffer.asUint8List()); // iOS 13+ requires TLS 1.3 or 1.2 minimum // This is enforced by the SecurityContext default return context; } } ``` **Info.plist Configuration:** ```xml NSAppTransportSecurity NSAllowsArbitraryLoads NSExceptionDomains tailscale-device.tailnet.ts.net NSExceptionMinimumTLSVersion TLSv1.2 NSExceptionRequiresForwardSecrecy ``` #### Android Specifics ```dart import 'dart:io'; import 'package:flutter/services.dart'; class AndroidCertificateTrust { /// Android 7+ (API 24+) uses Network Security Config for certificate trust /// /// Add to android/app/src/main/res/xml/network_security_config.xml: /// /// /// /// 100.x.x.x /// /// /// /// /// /// /// Reference in AndroidManifest.xml: /// static Future getSecureContext() async { final context = SecurityContext(withTrustedRoots: true); // Android accepts custom trust anchors via SecurityContext final certData = await rootBundle.load('assets/certs/bridge.crt'); context.setTrustedCertificatesBytes(certData.buffer.asUint8List()); return context; } } ``` **network_security_config.xml:** ```xml 100.64.0.0 ``` #### Flutter Certificate Validation ```dart import 'dart:io'; import 'package:flutter/services.dart'; class BridgeCertificateValidator { static String? _pinnedHash; /// Initialize with certificate pinning hash static Future initialize() async { // Load from secure config or build-time asset _pinnedHash = await _loadPinningHash(); } /// Custom certificate validation callback static bool validateCertificate( X509Certificate certificate, String host, int port, ) { if (_pinnedHash == null) return true; // Pinning not configured final certHash = _computePinningHash(certificate); if (certHash != _pinnedHash) { // Certificate doesn't match pin // Log security event return false; } return true; } static String _computePinningHash(X509Certificate cert) { // Compute SHA-256 of certificate SPKI final data = cert.der; // Hash computation... return 'sha256/${base64Encode(sha256.convert(data).bytes)}'; } static Future _loadPinningHash() async { try { final hash = await rootBundle.loadString('assets/certs/pinning_hash.txt'); return hash.trim(); } catch (e) { return null; } } } ``` ### Certificate Rotation Strategy ```typescript // Bridge server certificate management interface CertificateRotation { activeCert: Certificate; nextCert?: Certificate; // Pre-generated, will activate soon previousCert?: Certificate; // Previous, still valid for grace period gracePeriodDays: number; } class CertificateManager { private rotation: CertificateRotation; async rotateCertificate(): Promise { // Generate new certificate const newCert = await generateSelfSignedCertificate({ certDir: './certs', validityDays: 365, keySize: 2048, }); // Stage as next certificate this.rotation.nextCert = newCert; // Notify connected clients of upcoming rotation this.broadcastToClients({ type: 'certificate_rotation_scheduled', payload: { newFingerprint: newCert.fingerprint, effectiveDate: Date.now() + 24 * 60 * 60 * 1000, // 24 hours }, }); } async activateRotatedCertificate(): Promise { if (!this.rotation.nextCert) { throw new Error('No staged certificate available'); } // Move current to previous this.rotation.previousCert = this.rotation.activeCert; // Activate new certificate this.rotation.activeCert = this.rotation.nextCert; this.rotation.nextCert = undefined; // Server will use new cert for new connections // Old connections remain valid until closed } } ``` ### Trust On First Use (TOFU) For development scenarios, implement TOFU pattern: ```dart class TOFUTrustManager { static const String _prefsKey = 'bridge_cert_fingerprint'; /// Check certificate against stored fingerprint static Future verifyOrTrustCertificate( X509Certificate certificate, String host, ) async { final currentFingerprint = _computeFingerprint(certificate); final storedFingerprint = await _getStoredFingerprint(); if (storedFingerprint == null) { // First time seeing this certificate - trust it await _storeFingerprint(currentFingerprint); return true; } if (storedFingerprint != currentFingerprint) { // Certificate changed! return false; // Trigger user confirmation } return true; } static Future clearStoredFingerprint() async { final prefs = await SharedPreferences.getInstance(); await prefs.remove(_prefsKey); } static String _computeFingerprint(X509Certificate cert) { // SHA-256 of certificate } static Future _getStoredFingerprint() async { final prefs = await SharedPreferences.getInstance(); return prefs.getString(_prefsKey); } static Future _storeFingerprint(String fingerprint) async { final prefs = await SharedPreferences.getInstance(); await prefs.setString(_prefsKey, fingerprint); } } ``` --- ## Related Documentation - [Architecture Overview](../../architecture/system-overview/) — System architecture - [Bridge Protocol](../../architecture/bridge-protocol/) — WebSocket specification - [Bridge HTTP API](../../reference/bridge-http-api/) — HTTP endpoints specification - [Claude Code Hooks Integration](../../integrations/claude-code-hooks/) — Hook security - [Agent SDK Integration](../../integrations/agent-sdk/) — Agent SDK security - [Error Handling](../../reference/error-handling/) — Error codes and recovery --- *Last updated: 2026-03-20* --- Route: /ReCursor/operations/testing-strategy/ Source: docs-site/src/content/docs/operations/testing-strategy.md Title: Testing Strategy Description: Comprehensive testing approach for ReCursor, a Flutter app with WebSocket connections and Claude Code integrations. > Comprehensive testing approach for ReCursor, a Flutter app with WebSocket connections and Claude Code integrations. --- ## Testing Pyramid ``` / E2E \ patrol - full user journeys on real devices /----------\ / Integration \ Local WS server + integration_test /----------------\ / Widget Tests \ flutter_test widget tester + mock providers /----------------------\ / Unit Tests \ flutter_test + mockito/mocktail /--------------------------\ ``` --- ## Unit Testing **Tools:** `flutter_test`, `mockito` or `mocktail` ### WebSocket Mocking Pattern ```dart // Create a StreamController to simulate server messages final controller = StreamController(); final mockChannel = MockWebSocketChannel(controller.stream); // Inject via Riverpod override final container = ProviderContainer(overrides: [ webSocketProvider.overrideWithValue(mockChannel), ]); // Simulate server messages controller.add('{"type": "response", "data": "Hello"}'); // Assert with stream matchers expectLater( service.messages, emitsInOrder([isA()]), ); ``` ### Key Rules - Mock WebSocket with `StreamController`, not Mockito directly on streams. - Use `thenAnswer` (not `thenReturn`) for anything returning a Future or Stream. - Use `expectLater` with `emitsInOrder` / `emits` / `emitsDone` for async stream assertions. - Call `expectLater` **before** the stream emits to avoid missing events. ### What to Unit Test - WebSocket service (connect, disconnect, reconnect, message parsing) - Bridge connection state transitions (disconnected -> connecting -> connected -> error) - Git command serialization/deserialization - Notification payload parsing - Diff parsing logic - Sync queue operations and conflict resolution - Claude Code Hook event parsing --- ## Widget Testing **Tools:** `flutter_test` widget tester ### Pattern ```dart testWidgets('shows connected status', (tester) async { await tester.pumpWidget( ProviderScope( overrides: [ connectionStateProvider.overrideWith((_) => ConnectionState.connected), ], child: const MaterialApp(home: ChatScreen()), ), ); expect(find.text('Connected'), findsOneWidget); }); ``` ### What to Widget Test - Chat UI with mock message streams - Bridge QR pairing screen - OpenCode-style Tool Cards with sample data - Diff viewer with sample diff data - Approval UI approve/reject/modify interactions - Connection state indicators (connected, disconnected, reconnecting) - Repository list and file browser - Session timeline rendering ### OpenCode UI Component Testing ```dart testWidgets('renders tool card with correct status', (tester) async { final toolUse = ToolUse( tool: 'edit_file', params: {'file_path': 'test.dart'}, ); await tester.pumpWidget( MaterialApp( home: Scaffold( body: ToolCard( tool: toolUse, status: ToolStatus.completed, ), ), ), ); expect(find.byIcon(Icons.check_circle), findsOneWidget); expect(find.text('edit_file'), findsOneWidget); }); ``` --- ## Golden Tests (Visual Regression) **Tool:** `alchemist` - Capture baseline screenshots for key screens and states. - Connection states: connected, disconnected, reconnecting. - Chat: empty, loading, with messages, with streaming response. - Diff viewer: added lines, removed lines, modified files. - Tool cards: pending, running, completed, error states. - Run on CI to catch unintended visual changes. --- ## Integration Testing **Tools:** `integration_test` package + local Dart WebSocket server ### Pattern ```dart setUpAll(() async { // Start a local WebSocket server that replays scripted messages testServer = await TestBridgeServer.start(port: 8765); }); testWidgets('full chat flow', (tester) async { await tester.pumpWidget(const MyApp()); // Connect to local bridge await tester.tap(find.byKey(Key('connect_button'))); await tester.pumpAndSettle(); // Send a message await tester.enterText(find.byType(TextField), 'Fix the bug'); await tester.tap(find.byKey(Key('send_button'))); // Wait for streamed response await tester.pumpAndSettle(Duration(seconds: 2)); expect(find.textContaining('Fixed'), findsOneWidget); }); ``` ### What to Integration Test - Bridge connect -> validate pairing -> chat -> receive response - Git operation flows (commit, push, pull) - Approval flow (receive tool call -> approve -> agent continues) - Offline -> reconnect -> sync - Hook event flow (Claude Code -> Hooks -> Bridge -> Mobile) ### Test Bridge Server ```typescript // Local TypeScript server for integration tests import { WebSocketServer } from 'ws'; class TestBridgeServer { private wss: WebSocketServer; private scenarios: Map; start(port: number) { this.wss = new WebSocketServer({ port }); this.wss.on('connection', (ws) => { ws.on('message', (data) => { const msg = JSON.parse(data.toString()); // Replay scripted responses const responses = this.scenarios.get(msg.type) || []; for (const response of responses) { ws.send(JSON.stringify(response)); } }); }); } } ``` --- ## E2E Testing **Tool:** `patrol` - Complete user journeys on real or emulated devices. - Includes system-level interactions (notifications, deep links). - Run on `main` branch merges (too slow for every PR). ### E2E Scenarios - Full onboarding flow: install -> bridge pairing -> first message - Background notification: receive approval request -> tap notification -> approve - Multi-session: switch between agent sessions - Offline workflow: actions while offline -> sync on reconnect --- ## CI Integration | Trigger | Tests Run | |---------|-----------| | PR opened/updated | Unit + Widget + Golden + `flutter analyze` | | Push to `main` | All above + Integration | | Release tag | All above + E2E on physical devices | --- ## Testing Conventions ### Mock Data ```dart class TestData { static ToolUse sampleToolUse = ToolUse( tool: 'edit_file', params: { 'file_path': 'lib/main.dart', 'old_string': 'void main() {', 'new_string': 'void main() async {', }, ); static DiffFile sampleDiffFile = DiffFile( path: 'lib/main.dart', status: FileChangeStatus.modified, additions: 1, deletions: 1, hunks: [ DiffHunk( header: '@@ -10,5 +10,5 @@', oldStart: 10, oldLines: 5, newStart: 10, newLines: 5, lines: [ DiffLine(type: DiffLineType.context, content: ' class MyApp {'), DiffLine(type: DiffLineType.removed, content: '- void main() {'), DiffLine(type: DiffLineType.added, content: '+ void main() async {'), DiffLine(type: DiffLineType.context, content: ' // ...'), ], ), ], ); } ``` ### Async Test Helpers ```dart // Helper to wait for Riverpod state changes Future pumpUntilFound( WidgetTester tester, Finder finder, { Duration timeout = const Duration(seconds: 10), }) async { final endTime = DateTime.now().add(timeout); while (DateTime.now().isBefore(endTime)) { await tester.pumpAndSettle(const Duration(milliseconds: 100)); if (finder.evaluate().isNotEmpty) return; } throw TimeoutException('Finder not found within $timeout'); } ``` --- ## Claude Code Integration Testing ### Hook Event Testing ```dart test('parses PostToolUse hook event', () { final json = { 'event_type': 'PostToolUse', 'session_id': 'sess-abc', 'timestamp': '2026-03-17T10:32:00Z', 'payload': { 'tool': 'edit_file', 'result': {'success': true}, }, }; final event = HookEvent.fromJson(json); expect(event.eventType, 'PostToolUse'); expect(event.sessionId, 'sess-abc'); }); ``` ### Bridge Integration Testing ```dart testWidgets('displays Claude Code event from bridge', (tester) async { final bridge = MockBridgeService(); when(bridge.eventStream).thenAnswer((_) => Stream.fromIterable([ HookEvent.postToolUse( tool: 'edit_file', result: ToolResult.success(), ), ])); await tester.pumpWidget( ProviderScope( overrides: [ bridgeProvider.overrideWithValue(bridge), ], child: const ChatScreen(), ), ); await tester.pump(); expect(find.byType(ToolCard), findsOneWidget); }); ``` --- ## Related Documentation - [CI/CD Pipeline](../ci-cd/) — CI/CD configuration - [Architecture Overview](../../architecture/system-overview/) — System architecture - [Bridge Protocol](../../architecture/bridge-protocol/) — WebSocket message specification --- *Last updated: 2026-03-17* --- Route: /ReCursor/reference/bridge-http-api/ Source: docs-site/src/content/docs/reference/bridge-http-api.md Title: Bridge HTTP API Specification Description: REST endpoint specification for the ReCursor bridge server. Complements the WebSocket protocol with HTTP endpoints for hook ingestion, health checks, and control operations. > REST endpoint specification for the ReCursor bridge server. Complements the WebSocket protocol with HTTP endpoints for hook ingestion, health checks, and control operations. --- ## Overview The ReCursor bridge server implements a **dual transport pattern**: | Transport | Purpose | Protocol | |-----------|---------|----------| | WebSocket | Real-time bidirectional streaming | `wss://` | | HTTP | Hook ingestion, health checks, control | `https://` | This design aligns with patterns from benchmark repositories (cc-remote-control-server, BAREclaw) where WebSocket handles interactive sessions while HTTP provides stateless endpoints for external integrations. --- ## Base URL ``` https://:/api/v1 ``` **Connection Modes:** - **Local development**: `https://127.0.0.1:3000/api/v1` - **Tailscale/WireGuard**: `https://100.x.x.x:3000/api/v1` - **Custom domain**: `https://bridge.example.com:3000/api/v1` --- ## Authentication All HTTP endpoints require authentication via Bearer token in the `Authorization` header. ```http Authorization: Bearer ``` | Token Type | Endpoint Category | Source | |------------|-------------------|--------| | Device Token | `/health`, `/ws` pairing | Generated at QR pairing | | Hook Token | `/hooks/*` | Bridge server env var | | Admin Token | `/admin/*` | Bridge server env var | ### Token Validation Response ```json { "error": "Unauthorized", "message": "Invalid or expired token", "code": "AUTH_INVALID_TOKEN" } ``` --- ## Endpoints ### Health & Discovery #### GET /health Returns bridge health status and connection metadata. **Request:** ```bash curl -H "Authorization: Bearer $DEVICE_TOKEN" \ https://100.78.42.15:3000/api/v1/health ``` **Response 200:** ```json { "status": "healthy", "version": "1.0.0", "uptime_seconds": 86400, "connection_mode": "secure_remote", "active_sessions": 3, "active_websockets": 2, "system": { "platform": "darwin", "node_version": "20.11.0", "memory_mb": 512 }, "timestamp": "2026-03-20T14:32:00.000Z" } ``` **Response 503 (Degraded):** ```json { "status": "degraded", "version": "1.0.0", "checks": { "websocket_server": "healthy", "hook_endpoint": "healthy", "agent_sdk": "unhealthy", "disk_space": "healthy" }, "timestamp": "2026-03-20T14:32:00.000Z" } ``` --- #### GET /info Returns bridge capabilities and supported features. **Response 200:** ```json { "name": "recursor-bridge", "version": "1.0.0", "protocol_version": "1.0", "features": [ "websocket_sessions", "hook_events", "agent_sdk", "pty_sessions", "file_sync" ], "supported_agents": ["claude-code", "opencode", "aider", "goose"], "supported_hooks": [ "SessionStart", "SessionEnd", "PreToolUse", "PostToolUse", "UserPromptSubmit", "Stop", "SubagentStop" ], "limits": { "max_sessions": 10, "max_websocket_connections": 5, "max_message_size_mb": 10, "hook_timeout_seconds": 30 } } ``` --- ### Hook Event Ingestion #### POST /hooks/event Receives events from Claude Code Hooks. This is the primary ingress point for hook events. **Request Headers:** ```http Content-Type: application/json Authorization: Bearer X-Hook-Source: claude-code X-Hook-Version: 1.0 ``` **Request Body:** ```json { "event": "PreToolUse", "timestamp": "2026-03-20T14:32:00.000Z", "session_id": "sess-abc123", "payload": { "tool": "edit_file", "params": { "path": "src/main.ts", "content": "..." }, "risk_level": "medium" } } ``` **Response 200:** ```json { "received": true, "event_id": "evt-uuid-123", "broadcast_count": 2, "timestamp": "2026-03-20T14:32:00.050Z" } ``` **Response 400 (Validation Error):** ```json { "error": "ValidationError", "message": "Invalid event format: missing required field 'event'", "code": "HOOK_INVALID_PAYLOAD", "details": { "field": "event", "constraint": "required" } } ``` **Response 401:** ```json { "error": "Unauthorized", "message": "Invalid hook token", "code": "HOOK_AUTH_FAILED" } ``` --- #### POST /hooks/batch Batch event ingestion for high-frequency scenarios. **Request Body:** ```json { "events": [ { "event": "PreToolUse", "timestamp": "2026-03-20T14:32:00.000Z", "session_id": "sess-abc123", "payload": { ... } }, { "event": "PostToolUse", "timestamp": "2026-03-20T14:32:01.000Z", "session_id": "sess-abc123", "payload": { ... } } ] } ``` **Response 200:** ```json { "received": true, "count": 2, "accepted": 2, "rejected": 0, "event_ids": ["evt-1", "evt-2"] } ``` --- ### Session Management #### GET /sessions List active sessions. **Response 200:** ```json { "sessions": [ { "id": "sess-abc123", "agent_type": "claude-code", "title": "Bridge startup validation", "working_directory": "/home/user/recursor", "status": "active", "created_at": "2026-03-20T14:00:00.000Z", "last_activity_at": "2026-03-20T14:32:00.000Z", "websocket_connected": true, "hook_count": 15 } ], "total": 1 } ``` --- #### GET /sessions/:id Get detailed session information. **Response 200:** ```json { "id": "sess-abc123", "agent_type": "claude-code", "title": "Bridge startup validation", "working_directory": "/home/user/recursor", "status": "active", "created_at": "2026-03-20T14:00:00.000Z", "last_activity_at": "2026-03-20T14:32:00.000Z", "websocket_connected": true, "hook_count": 15, "recent_events": [ { "type": "PreToolUse", "timestamp": "2026-03-20T14:32:00.000Z", "tool": "read_file" } ] } ``` **Response 404:** ```json { "error": "NotFound", "message": "Session not found: sess-abc123", "code": "SESSION_NOT_FOUND" } ``` --- #### POST /sessions/:id/events Send an event to a specific session (for Agent SDK integration). **Request Body:** ```json { "type": "user_message", "content": "Please review the changes", "metadata": { "source": "mobile_app", "client_version": "1.0.0" } } ``` **Response 202:** ```json { "accepted": true, "event_id": "evt-user-123", "session_id": "sess-abc123" } ``` --- ### WebSocket Upgrade #### GET /ws WebSocket upgrade endpoint. Returns 400 for non-WebSocket requests. **Request Headers:** ```http Upgrade: websocket Connection: Upgrade Authorization: Bearer Sec-WebSocket-Key: Sec-WebSocket-Version: 13 ``` **Response 101:** WebSocket upgrade successful. **Response 400:** ```json { "error": "BadRequest", "message": "WebSocket upgrade required", "code": "WS_UPGRADE_REQUIRED", "websocket_url": "wss://100.78.42.15:3000/api/v1/ws" } ``` --- ### File Operations #### GET /files/tree Get file tree for a working directory. **Query Parameters:** | Parameter | Type | Description | |-----------|------|-------------| | `path` | string | Absolute or relative path | | `depth` | number | Max depth (default: 3) | **Response 200:** ```json { "path": "/home/user/recursor", "entries": [ { "name": "src", "type": "directory", "children": [ { "name": "main.ts", "type": "file", "size": 1024, "modified_at": "2026-03-20T14:00:00.000Z" } ] }, { "name": "package.json", "type": "file", "size": 512, "modified_at": "2026-03-20T13:00:00.000Z" } ] } ``` --- #### GET /files/content Get file content. **Query Parameters:** | Parameter | Type | Description | |-----------|------|-------------| | `path` | string | Absolute file path | **Response 200:** ```json { "path": "/home/user/recursor/src/main.ts", "content": "import { app } from './app';\n...", "size": 1024, "encoding": "utf-8", "modified_at": "2026-03-20T14:00:00.000Z" } ``` --- ### Admin Operations #### POST /admin/reload-hooks Reload hook configuration without restart. **Response 200:** ```json { "reloaded": true, "timestamp": "2026-03-20T14:32:00.000Z", "active_hooks": ["PreToolUse", "PostToolUse", "SessionStart"] } ``` --- ## Error Response Format All errors follow a consistent format: ```json { "error": "ErrorName", "message": "Human-readable description", "code": "UPPER_SNAKE_CASE_CODE", "request_id": "req-uuid-123", "timestamp": "2026-03-20T14:32:00.000Z", "documentation_url": "https://docs.recursor.dev/errors/UPPER_SNAKE_CASE_CODE" } ``` ### Error Codes | Code | HTTP Status | Description | |------|-------------|-------------| | `AUTH_INVALID_TOKEN` | 401 | Token missing, malformed, or expired | | `AUTH_INSUFFICIENT_SCOPE` | 403 | Valid token lacks required scope | | `HOOK_INVALID_PAYLOAD` | 400 | Event validation failed | | `HOOK_AUTH_FAILED` | 401 | Hook token invalid | | `SESSION_NOT_FOUND` | 404 | Session ID does not exist | | `SESSION_CLOSED` | 409 | Session is no longer active | | `RATE_LIMIT_EXCEEDED` | 429 | Too many requests | | `INTERNAL_ERROR` | 500 | Unexpected server error | --- ## Rate Limiting | Endpoint Category | Limit | Window | |-------------------|-------|--------| | `/health`, `/info` | 60 | 1 minute | | `/hooks/event` | 120 | 1 minute | | `/hooks/batch` | 10 | 1 minute | | `/sessions/*` | 30 | 1 minute | | `/files/*` | 60 | 1 minute | **Rate Limit Response:** ```json { "error": "RateLimitExceeded", "message": "Rate limit exceeded: 120 requests per minute", "code": "RATE_LIMIT_EXCEEDED", "retry_after": 45 } ``` --- ## CORS Configuration The bridge server enables CORS for development scenarios: ```http Access-Control-Allow-Origin: * Access-Control-Allow-Methods: GET, POST, OPTIONS Access-Control-Allow-Headers: Authorization, Content-Type, X-Request-ID Access-Control-Max-Age: 86400 ``` > **Note**: In production, configure specific origins rather than `*`. --- ## TLS Requirements All HTTP endpoints require TLS. See [security-architecture.md](../../operations/security-architecture/) for: - Self-signed certificate generation - Certificate pinning - Mobile platform TLS caveats --- *Last updated: 2026-03-20* --- Route: /ReCursor/reference/error-handling/ Source: docs-site/src/content/docs/reference/error-handling.md Title: Error Handling & Recovery Specification Description: Error taxonomy, session recovery patterns, and reconnection strategies for the ReCursor bridge protocol. Grounded in benchmark research from remote-claude, BAREclaw, and code-server patterns. > Error taxonomy, session recovery patterns, and reconnection strategies for the ReCursor bridge protocol. Grounded in benchmark research from remote-claude, BAREclaw, and code-server patterns. --- ## Overview ReCursor implements a **layered error handling strategy**: 1. **Transport Layer** — WebSocket connection drops, TLS failures 2. **Protocol Layer** — Message validation, sequence errors 3. **Application Layer** — Session failures, tool execution errors 4. **Recovery Layer** — Reconnection, replay, state reconciliation This document defines error taxonomies, recovery patterns, and implementation guidance for each layer. --- ## Error Taxonomy ### Error Categories | Category | Prefix | Description | Example | |----------|--------|-------------|-----------| | Connection | `CONN_` | Transport-level failures | `CONN_WEBSOCKET_CLOSED` | | Authentication | `AUTH_` | Token/identity failures | `AUTH_TOKEN_EXPIRED` | | Protocol | `PROTO_` | Message format violations | `PROTO_INVALID_MESSAGE` | | Session | `SESS_` | Session lifecycle errors | `SESS_NOT_FOUND` | | Tool | `TOOL_` | Tool execution failures | `TOOL_EXECUTION_FAILED` | | Hook | `HOOK_` | Hook event errors | `HOOK_VALIDATION_FAILED` | | System | `SYS_` | Internal server errors | `SYS_INTERNAL_ERROR` | ### Error Severity Levels | Level | Behavior | User Impact | |-------|----------|-------------| | `info` | Log only | None | | `warning` | Log + metrics | Minimal (degraded performance) | | `error` | Log + notify + retry | Moderate (temporary disruption) | | `critical` | Log + alert + circuit break | High (service unavailable) | | `fatal` | Log + terminate | Complete session loss | --- ## Connection Errors ### CONN_WEBSOCKET_CLOSED **Trigger**: WebSocket connection unexpectedly closed. **Client Behavior:** ```dart // Dart client implementation class BridgeConnection { static const RECONNECT_DELAYS = [1000, 2000, 5000, 10000, 30000]; Future handleDisconnect(DisconnectReason reason) async { if (reason.isRecoverable) { await attemptReconnect(); } else { await transitionToErrorState(reason); } } Future attemptReconnect() async { for (final delay in RECONNECT_DELAYS) { await Future.delayed(Duration(milliseconds: delay)); try { await connect(); await requestReplayBuffer(); // Request missed messages return; } catch (e) { continue; } } throw ReconnectionExhaustedError(); } } ``` **Server Behavior:** ```typescript // Bridge server - keep session alive during reconnect window const SESSION_GRACE_MS = 5 * 60 * 1000; // 5 minutes interface SessionState { id: string; websocket: WebSocket | null; replayBuffer: string[]; graceTimer: NodeJS.Timeout | null; } function handleDisconnect(sessionId: string) { const session = sessions.get(sessionId); if (!session) return; session.websocket = null; session.graceTimer = setTimeout(() => { closeSession(sessionId); // Grace period expired }, SESSION_GRACE_MS); } function handleReconnect(sessionId: string, ws: WebSocket) { const session = sessions.get(sessionId); if (!session) { throw new Error('SESS_NOT_FOUND'); } clearTimeout(session.graceTimer); session.websocket = ws; // Send replay buffer if (session.replayBuffer.length > 0) { ws.send(JSON.stringify({ type: 'replay_buffer', payload: { messages: session.replayBuffer } })); } } ``` --- ### CONN_TLS_HANDSHAKE_FAILED **Trigger**: TLS certificate validation failed. **Resolution Steps:** 1. Check certificate expiry 2. Verify certificate chain 3. Check hostname mismatch 4. For self-signed certs: confirm pinning hash **Client Response:** ```json { "type": "connection_error", "payload": { "code": "CONN_TLS_HANDSHAKE_FAILED", "message": "TLS certificate validation failed", "details": { "reason": "CERTIFICATE_EXPIRED", "expiry": "2026-03-01T00:00:00Z", "suggested_action": "regenerate_certificates" }, "recoverable": false } } ``` --- ### CONN_TIMEOUT **Trigger**: Connection attempt exceeded timeout. **Retry Strategy:** | Attempt | Delay | Action | |---------|-------|--------| | 1 | 1s | Immediate retry | | 2 | 2s | Retry with cached IP | | 3 | 5s | Retry with DNS refresh | | 4+ | 10s | Retry with exponential backoff | --- ## Protocol Errors ### PROTO_INVALID_MESSAGE **Trigger**: Message failed schema validation. **Validation Rules:** ```typescript interface ProtocolMessage { type: string; // Required, non-empty id: string; // Required, UUID format timestamp: string; // Required, ISO 8601 payload: unknown; // Required, object } const validationRules = { type: [required(), matches(/^[a-z_]+$/)], id: [required(), uuid()], timestamp: [required(), iso8601()], payload: [required(), object()] }; ``` **Error Response:** ```json { "type": "error", "id": "msg-123", "payload": { "code": "PROTO_INVALID_MESSAGE", "message": "Message validation failed", "violations": [ { "field": "timestamp", "constraint": "iso8601", "received": "2026-03-20 14:32:00" } ] } } ``` --- ### PROTO_SEQUENCE_ERROR **Trigger**: Message received out of expected sequence. **Scenarios:** - `auth` message not first - `health_check` before `connection_ack` - `session_message` before `session_join` **Recovery:** ```typescript function validateSequence(message: ProtocolMessage, state: ConnectionState): void { const expected = SEQUENCE_MAP[state.currentPhase]; if (!expected.includes(message.type)) { throw new ProtocolError('PROTO_SEQUENCE_ERROR', { expected, received: message.type, currentPhase: state.currentPhase }); } } ``` --- ## Session Errors ### SESS_NOT_FOUND **Trigger**: Referenced session does not exist. **HTTP Response:** ```json { "error": "NotFound", "code": "SESS_NOT_FOUND", "message": "Session 'sess-abc123' not found", "suggestions": [ "Check session ID spelling", "Session may have expired", "Use GET /sessions to list active sessions" ] } ``` --- ### SESS_CLOSED **Trigger**: Operation attempted on closed session. **Session States:** ``` CREATED → ACTIVE → PAUSED → CLOSED ↓ ↓ ↓ ERROR ERROR RESUMABLE ``` **Resumable Sessions:** Some sessions can be resumed after PAUSED state: ```typescript interface Session { id: string; state: 'created' | 'active' | 'paused' | 'closed'; resumable: boolean; checkpoint: SessionCheckpoint | null; } async function resumeSession(sessionId: string): Promise { const session = await loadSession(sessionId); if (session.state !== 'paused' || !session.resumable) { throw new Error('SESS_NOT_RESUMABLE'); } // Restore from checkpoint await restoreCheckpoint(session.checkpoint); session.state = 'active'; return session; } ``` --- ## Tool Execution Errors ### TOOL_EXECUTION_FAILED **Trigger**: Tool execution returned non-zero exit code or exception. **Error Structure:** ```json { "type": "tool_error", "payload": { "tool_call_id": "tool-abc123", "tool": "bash", "code": "TOOL_EXECUTION_FAILED", "message": "Command exited with code 1", "details": { "exit_code": 1, "stderr": "error: file not found", "stdout": "", "execution_time_ms": 150 }, "retryable": true, "max_retries": 3 } } ``` **Retryable vs Non-Retryable:** | Error | Retryable | Strategy | |-------|-----------|----------| | Network timeout | Yes | Exponential backoff | | File not found | No | Fail immediately | | Permission denied | No | Fail immediately | | Rate limited | Yes | Backoff with jitter | | Out of memory | Maybe | Retry once, then fail | --- ### TOOL_TIMEOUT **Trigger**: Tool execution exceeded maximum duration. **Configuration:** ```typescript const TOOL_TIMEOUTS = { bash: 300000, // 5 minutes read_file: 10000, // 10 seconds edit_file: 30000, // 30 seconds search: 60000 // 1 minute }; ``` --- ## Hook Event Errors ### HOOK_VALIDATION_FAILED **Trigger**: Hook event failed schema validation. **Validation Schema:** ```typescript const HookEventSchema = z.object({ event: z.enum(['SessionStart', 'SessionEnd', 'PreToolUse', 'PostToolUse', 'UserPromptSubmit', 'Stop', 'SubagentStop']), timestamp: z.string().datetime(), session_id: z.string().min(1), payload: z.record(z.unknown()) }); ``` **Response:** ```json { "received": false, "code": "HOOK_VALIDATION_FAILED", "errors": [ { "field": "event", "message": "Invalid enum value. Expected one of: SessionStart, SessionEnd..." } ] } ``` --- ## Recovery Patterns ### Replay Buffer Pattern Based on remote-claude's implementation: ```typescript interface ReplayBuffer { maxSize: number; // 100KB default maxAge: number; // 30 minutes buffer: string[]; append(message: string): void { this.buffer.push(message); const size = JSON.stringify(this.buffer).length; // Trim by size while (size > this.maxSize && this.buffer.length > 0) { this.buffer.shift(); } } getReplay(since?: Date): string[] { if (!since) return [...this.buffer]; return this.buffer.filter(msg => msg.timestamp > since); } } ``` --- ### Circuit Breaker Pattern For external service calls (Agent SDK, file system): ```typescript class CircuitBreaker { private failures = 0; private lastFailureTime: number | null = null; private state: 'closed' | 'open' | 'half-open' = 'closed'; constructor( private threshold = 5, private timeoutMs = 60000 ) {} async execute(fn: () => Promise): Promise { if (this.state === 'open') { if (Date.now() - (this.lastFailureTime || 0) > this.timeoutMs) { this.state = 'half-open'; } else { throw new Error('CIRCUIT_OPEN'); } } try { const result = await fn(); this.onSuccess(); return result; } catch (e) { this.onFailure(); throw e; } } private onSuccess() { this.failures = 0; this.state = 'closed'; } private onFailure() { this.failures++; this.lastFailureTime = Date.now(); if (this.failures >= this.threshold) { this.state = 'open'; } } } ``` --- ### Session Persistence Pattern Based on BAREclaw's session recovery: ```typescript interface PersistedSession { id: string; agentType: string; workingDirectory: string; createdAt: string; lastActivityAt: string; checkpoint: { messageCount: number; lastMessageId: string; contextSnapshot: unknown; }; } async function saveSessions(sessions: PersistedSession[]): Promise { const data = JSON.stringify(sessions, null, 2); await fs.writeFile(SESSIONS_FILE, data); } async function loadSessions(): Promise { try { const data = await fs.readFile(SESSIONS_FILE, 'utf-8'); return JSON.parse(data); } catch { return []; } } ``` --- ## Client-Side Recovery ### Dart Implementation ```dart class BridgeConnectionRecovery { static const MAX_RETRIES = 5; static const BASE_DELAY = Duration(seconds: 1); final List _pendingMessages = []; DateTime? _lastReceivedMessageTime; Future reconnect() async { for (var attempt = 0; attempt < MAX_RETRIES; attempt++) { try { await _connect(); await _synchronizeState(); _flushPendingMessages(); return; } catch (e) { final delay = BASE_DELAY * (attempt + 1); await Future.delayed(delay); } } throw BridgeConnectionException('Max retries exceeded'); } Future _synchronizeState() async { // Request replay since last known message if (_lastReceivedMessageTime != null) { await sendMessage(BridgeMessage.requestReplay( since: _lastReceivedMessageTime! )); } } void _flushPendingMessages() { while (_pendingMessages.isNotEmpty) { final msg = _pendingMessages.removeAt(0); sendMessage(msg); } } void onDisconnect() { // Queue outgoing messages during disconnect _messageController.stream.listen((msg) { if (!isConnected) { _pendingMessages.add(msg); } }); } } ``` --- ## Error Metrics & Monitoring ### Metric Labels ```typescript interface ErrorMetric { category: string; // Error category prefix code: string; // Full error code severity: string; // info/warning/error/critical/fatal source: string; // client/server/hook session_id?: string; // Associated session user_agent?: string; // Client version } ``` ### Alert Thresholds | Metric | Warning | Critical | |--------|---------|----------| | Error rate | > 5% | > 15% | | Reconnection failures | > 10/min | > 50/min | | Session drops | > 5/min | > 20/min | | Hook validation failures | > 20/min | > 100/min | --- *Last updated: 2026-03-20* --- Route: /ReCursor/reference/type-mapping/ Source: docs-site/src/content/docs/reference/type-mapping.md Title: Dart ↔ TypeScript Type Mapping Specification Description: Cross-language contract defining type-safe serialization between Flutter (Dart) and Bridge (TypeScript). TypeScript protocol is source-of-truth. > Cross-language contract defining type-safe serialization between Flutter (Dart) and Bridge (TypeScript). TypeScript protocol is source-of-truth. --- ## Overview ReCursor uses a **TypeScript-first protocol** where: - TypeScript types are the **source of truth** - Dart types are **derived** and must match exactly - JSON is the **wire format** for both directions This document defines the mapping rules, edge cases, and validation requirements for maintaining type safety across the language boundary. --- ## Primitive Type Mapping | TypeScript | Dart | JSON | Notes | |------------|------|------|-------| | `string` | `String` | string | UTF-8 encoded | | `number` | `double` | number | All numbers are doubles in JSON | | `number` (int) | `int` | number | Use `int` in Dart for integer values | | `boolean` | `bool` | boolean | | | `null` | `null` | null | | | `undefined` | N/A | absent | Use nullable types in Dart | | `Date` | `DateTime` | ISO 8601 string | Always UTC in transit | | `bigint` | `int` | string | Serialize as string to avoid precision loss | | `Uint8Array` | `Uint8List` | base64 string | Binary data encoding | --- ## String Enums TypeScript string enums map to Dart enums with explicit string values. ### TypeScript (Source of Truth) ```typescript export enum ConnectionMode { LocalOnly = 'local_only', PrivateNetwork = 'private_network', SecureRemote = 'secure_remote', DirectPublic = 'direct_public', Misconfigured = 'misconfigured' } export enum MessageType { Auth = 'auth', ConnectionAck = 'connection_ack', HealthCheck = 'health_check', HealthStatus = 'health_status', SessionStarted = 'session_started', SessionEnded = 'session_ended', ToolUse = 'tool_use', ToolResult = 'tool_result', Error = 'error' } export enum RiskLevel { Low = 'low', Medium = 'medium', High = 'high', Critical = 'critical' } ``` ### Dart (Derived) ```dart enum ConnectionMode { localOnly('local_only'), privateNetwork('private_network'), secureRemote('secure_remote'), directPublic('direct_public'), misconfigured('misconfigured'); final String value; const ConnectionMode(this.value); factory ConnectionMode.fromString(String value) { return ConnectionMode.values.firstWhere( (e) => e.value == value, orElse: () => throw ArgumentError('Unknown ConnectionMode: $value'), ); } } enum MessageType { auth('auth'), connectionAck('connection_ack'), healthCheck('health_check'), healthStatus('health_status'), sessionStarted('session_started'), sessionEnded('session_ended'), toolUse('tool_use'), toolResult('tool_result'), error('error'); final String value; const MessageType(this.value); factory MessageType.fromString(String value) { return MessageType.values.firstWhere( (e) => e.value == value, orElse: () => throw ArgumentError('Unknown MessageType: $value'), ); } } enum RiskLevel { low('low'), medium('medium'), high('high'), critical('critical'); final String value; const RiskLevel(this.value); factory RiskLevel.fromString(String value) { return RiskLevel.values.firstWhere( (e) => e.value == value, orElse: () => throw ArgumentError('Unknown RiskLevel: $value'), ); } } ``` --- ## Complex Types ### ProtocolMessage Base message type for all WebSocket communication. #### TypeScript (Source of Truth) ```typescript export interface ProtocolMessage { type: MessageType; id: string; // UUID v4 timestamp: string; // ISO 8601 UTC payload: unknown; } export interface AuthPayload { token: string; client_version: string; platform: 'ios' | 'android' | 'web'; device_id?: string; } export interface ConnectionAckPayload { server_version: string; supported_agents: string[]; connection_mode: ConnectionMode; connection_mode_description: string; bridge_url: string; requires_health_verification: boolean; active_sessions: SessionInfo[]; } export interface SessionInfo { session_id: string; agent: string; title: string; working_directory?: string; } ``` #### Dart (Derived) ```dart @JsonSerializable() class ProtocolMessage { final MessageType type; final String id; final DateTime timestamp; final Map payload; ProtocolMessage({ required this.type, required this.id, required this.timestamp, required this.payload, }); factory ProtocolMessage.fromJson(Map json) => _$ProtocolMessageFromJson(json); Map toJson() => _$ProtocolMessageToJson(this); } @JsonSerializable() class AuthPayload { final String token; final String clientVersion; final String platform; final String? deviceId; AuthPayload({ required this.token, required this.clientVersion, required this.platform, this.deviceId, }); factory AuthPayload.fromJson(Map json) => _$AuthPayloadFromJson(json); Map toJson() => _$AuthPayloadToJson(this); } @JsonSerializable() class ConnectionAckPayload { final String serverVersion; final List supportedAgents; final ConnectionMode connectionMode; final String connectionModeDescription; final String bridgeUrl; final bool requiresHealthVerification; final List activeSessions; ConnectionAckPayload({ required this.serverVersion, required this.supportedAgents, required this.connectionMode, required this.connectionModeDescription, required this.bridgeUrl, required this.requiresHealthVerification, required this.activeSessions, }); factory ConnectionAckPayload.fromJson(Map json) => _$ConnectionAckPayloadFromJson(json); Map toJson() => _$ConnectionAckPayloadToJson(this); } @JsonSerializable() class SessionInfo { final String sessionId; final String agent; final String title; final String? workingDirectory; SessionInfo({ required this.sessionId, required this.agent, required this.title, this.workingDirectory, }); factory SessionInfo.fromJson(Map json) => _$SessionInfoFromJson(json); Map toJson() => _$SessionInfoToJson(this); } ``` --- ## Hook Event Types ### TypeScript (Source of Truth) ```typescript export type HookEventType = | 'SessionStart' | 'SessionEnd' | 'PreToolUse' | 'PostToolUse' | 'UserPromptSubmit' | 'Stop' | 'SubagentStop' | 'PreCompact' | 'Notification'; export interface HookEvent { event: HookEventType; timestamp: string; // ISO 8601 UTC session_id: string; payload: HookEventPayload; } export type HookEventPayload = | SessionStartPayload | SessionEndPayload | PreToolUsePayload | PostToolUsePayload | UserPromptSubmitPayload | StopPayload; export interface SessionStartPayload { working_directory: string; initial_prompt?: string; } export interface SessionEndPayload { duration_seconds: number; message_count: number; exit_reason: 'user_exit' | 'error' | 'completion'; } export interface PreToolUsePayload { tool: string; params: Record; risk_level: RiskLevel; requires_approval: boolean; } export interface PostToolUsePayload { tool: string; params: Record; result: unknown; execution_time_ms: number; success: boolean; } export interface UserPromptSubmitPayload { prompt: string; context_files?: string[]; estimated_tokens?: number; } export interface StopPayload { reason: 'user_request' | 'tool_error' | 'max_tokens' | 'safety'; context?: string; } ``` ### Dart (Derived) ```dart @JsonSerializable() class HookEvent { final String event; final DateTime timestamp; final String sessionId; final Map payload; HookEvent({ required this.event, required this.timestamp, required this.sessionId, required this.payload, }); factory HookEvent.fromJson(Map json) => _$HookEventFromJson(json); Map toJson() => _$HookEventToJson(this); // Typed accessors SessionStartPayload? get asSessionStart => event == 'SessionStart' ? SessionStartPayload.fromJson(payload) : null; PreToolUsePayload? get asPreToolUse => event == 'PreToolUse' ? PreToolUsePayload.fromJson(payload) : null; PostToolUsePayload? get asPostToolUse => event == 'PostToolUse' ? PostToolUsePayload.fromJson(payload) : null; } @JsonSerializable() class SessionStartPayload { final String workingDirectory; final String? initialPrompt; SessionStartPayload({ required this.workingDirectory, this.initialPrompt, }); factory SessionStartPayload.fromJson(Map json) => _$SessionStartPayloadFromJson(json); Map toJson() => _$SessionStartPayloadToJson(this); } @JsonSerializable() class SessionEndPayload { final int durationSeconds; final int messageCount; final String exitReason; SessionEndPayload({ required this.durationSeconds, required this.messageCount, required this.exitReason, }); factory SessionEndPayload.fromJson(Map json) => _$SessionEndPayloadFromJson(json); Map toJson() => _$SessionEndPayloadToJson(this); } @JsonSerializable() class PreToolUsePayload { final String tool; final Map params; final RiskLevel riskLevel; final bool requiresApproval; PreToolUsePayload({ required this.tool, required this.params, required this.riskLevel, required this.requiresApproval, }); factory PreToolUsePayload.fromJson(Map json) => _$PreToolUsePayloadFromJson(json); Map toJson() => _$PreToolUsePayloadToJson(this); } @JsonSerializable() class PostToolUsePayload { final String tool; final Map params; final dynamic result; final int executionTimeMs; final bool success; PostToolUsePayload({ required this.tool, required this.params, required this.result, required this.executionTimeMs, required this.success, }); factory PostToolUsePayload.fromJson(Map json) => _$PostToolUsePayloadFromJson(json); Map toJson() => _$PostToolUsePayloadToJson(this); } @JsonSerializable() class UserPromptSubmitPayload { final String prompt; final List? contextFiles; final int? estimatedTokens; UserPromptSubmitPayload({ required this.prompt, this.contextFiles, this.estimatedTokens, }); factory UserPromptSubmitPayload.fromJson(Map json) => _$UserPromptSubmitPayloadFromJson(json); Map toJson() => _$UserPromptSubmitPayloadToJson(this); } @JsonSerializable() class StopPayload { final String reason; final String? context; StopPayload({ required this.reason, this.context, }); factory StopPayload.fromJson(Map json) => _$StopPayloadFromJson(json); Map toJson() => _$StopPayloadToJson(this); } ``` --- ## Error Types ### TypeScript (Source of Truth) ```typescript export interface BridgeError { code: string; // UPPER_SNAKE_CASE message: string; details?: Record; recoverable: boolean; retry_after_ms?: number; } export interface ErrorMessage { type: 'error'; id: string; payload: { code: string; message: string; original_message_id?: string; details?: Record; }; } ``` ### Dart (Derived) ```dart @JsonSerializable() class BridgeError { final String code; final String message; final Map? details; final bool recoverable; final int? retryAfterMs; BridgeError({ required this.code, required this.message, this.details, required this.recoverable, this.retryAfterMs, }); factory BridgeError.fromJson(Map json) => _$BridgeErrorFromJson(json); Map toJson() => _$BridgeErrorToJson(this); } @JsonSerializable() class ErrorMessage { final String type; final String id; final ErrorPayload payload; ErrorMessage({ required this.type, required this.id, required this.payload, }); factory ErrorMessage.fromJson(Map json) => _$ErrorMessageFromJson(json); Map toJson() => _$ErrorMessageToJson(this); } @JsonSerializable() class ErrorPayload { final String code; final String message; final String? originalMessageId; final Map? details; ErrorPayload({ required this.code, required this.message, this.originalMessageId, this.details, }); factory ErrorPayload.fromJson(Map json) => _$ErrorPayloadFromJson(json); Map toJson() => _$ErrorPayloadToJson(this); } ``` --- ## Date/Time Handling ### Rules 1. **All timestamps in transit are ISO 8601 UTC strings** 2. **TypeScript generates UTC**: `new Date().toISOString()` 3. **Dart parses to DateTime**: `DateTime.parse()` (assumes UTC if no timezone) 4. **Dart serializes to UTC**: `dateTime.toUtc().toIso8601String()` ### TypeScript ```typescript // Always use toISOString() for wire format const timestamp = new Date().toISOString(); // "2026-03-20T14:32:00.000Z" // Parse incoming const date = new Date(timestamp); // Converts to Date object ``` ### Dart ```dart // Serialization String serializeDateTime(DateTime dt) => dt.toUtc().toIso8601String(); // Deserialization DateTime deserializeDateTime(String iso) => DateTime.parse(iso).toUtc(); // JSON converter for json_serializable class UTCDateTimeConverter implements JsonConverter { const UTCDateTimeConverter(); @override DateTime fromJson(String json) => DateTime.parse(json).toUtc(); @override String toJson(DateTime dt) => dt.toUtc().toIso8601String(); } ``` --- ## Nullability Rules ### TypeScript → Dart Mapping | TypeScript | Dart | Notes | |------------|------|-------| | `string` | `String` | Non-nullable | | `string \| null` | `String?` | Nullable | | `string \| undefined` | `String?` | Nullable (undefined becomes null in JSON) | | `string?` (optional) | `String?` | Nullable | | `T[]` | `List` | Non-nullable list, non-nullable items | | `T[] \| null` | `List?` | Nullable list | | `(T \| null)[]` | `List` | Non-nullable list, nullable items | ### Example ```typescript interface Example { required: string; // → String optional?: string; // → String? nullable: string | null; // → String? array: string[]; // → List nullableArray: string[] | null; // → List? mixedArray: (string | null)[]; // → List } ``` ```dart @JsonSerializable() class Example { final String required; final String? optional; final String? nullable; final List array; final List? nullableArray; final List mixedArray; Example({ required this.required, this.optional, this.nullable, required this.array, this.nullableArray, required this.mixedArray, }); } ``` --- ## Validation Requirements ### TypeScript Validation (Zod) ```typescript import { z } from 'zod'; export const ProtocolMessageSchema = z.object({ type: z.enum(['auth', 'connection_ack', 'health_check', /* ... */]), id: z.string().uuid(), timestamp: z.string().datetime(), payload: z.record(z.unknown()), }); export const HookEventSchema = z.object({ event: z.enum(['SessionStart', 'SessionEnd', 'PreToolUse', /* ... */]), timestamp: z.string().datetime(), session_id: z.string().min(1), payload: z.record(z.unknown()), }); export function validateMessage(data: unknown): ProtocolMessage { return ProtocolMessageSchema.parse(data); } ``` ### Dart Validation ```dart class MessageValidator { static final _uuidRegex = RegExp( r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', caseSensitive: false, ); static void validateProtocolMessage(Map json) { if (!json.containsKey('type')) { throw ValidationError('Missing required field: type'); } if (!json.containsKey('id')) { throw ValidationError('Missing required field: id'); } if (!_uuidRegex.hasMatch(json['id'] as String)) { throw ValidationError('Invalid UUID format: ${json['id']}'); } if (!json.containsKey('timestamp')) { throw ValidationError('Missing required field: timestamp'); } try { DateTime.parse(json['timestamp'] as String); } catch (e) { throw ValidationError('Invalid timestamp format: ${json['timestamp']}'); } } } class ValidationError implements Exception { final String message; ValidationError(this.message); @override String toString() => 'ValidationError: $message'; } ``` --- ## Version Compatibility ### Protocol Versioning ```typescript // TypeScript interface VersionInfo { protocol_version: string; // "1.0.0" min_client_version: string; // "1.0.0" max_client_version: string; // "1.1.0" } function checkCompatibility( clientVersion: string, serverVersion: VersionInfo ): CompatibilityResult { if (compareVersions(clientVersion, serverVersion.min_client_version) < 0) { return { compatible: false, reason: 'CLIENT_TOO_OLD' }; } if (compareVersions(clientVersion, serverVersion.max_client_version) > 0) { return { compatible: false, reason: 'CLIENT_TOO_NEW' }; } return { compatible: true }; } ``` ```dart // Dart class VersionCompatibility { static bool check(String clientVersion, VersionInfo serverInfo) { final client = _parseVersion(clientVersion); final min = _parseVersion(serverInfo.minClientVersion); final max = _parseVersion(serverInfo.maxClientVersion); return client >= min && client <= max; } static Version _parseVersion(String v) { final parts = v.split('.').map(int.parse).toList(); return Version(parts[0], parts[1], parts[2]); } } ``` --- ## Code Generation ### TypeScript → Dart Workflow 1. Define types in TypeScript (source of truth) 2. Run code generator to produce Dart types 3. Commit both to repository 4. CI validates Dart types match TypeScript ### Generator Script (Conceptual) ```typescript // scripts/generate-dart-types.ts import { generateDartTypes } from './type-generator'; import * as fs from 'fs'; const typescriptTypes = fs.readFileSync('src/types/protocol.ts', 'utf-8'); const dartOutput = generateDartTypes(typescriptTypes, { useJsonSerializable: true, useFreezed: false, }); fs.writeFileSync('../apps/mobile/lib/core/models/protocol.g.dart', dartOutput); ``` --- *Last updated: 2026-03-20* --- Route: /ReCursor/legal/privacy-policy/ Source: docs-site/src/content/docs/legal/privacy-policy.md Title: Privacy Policy Description: **Last updated: 2026-03-17** **Last updated: 2026-03-17** ## Overview ReCursor ("we", "our", "the app") is an open-source mobile application for monitoring AI coding agent workflows. This policy describes what data we collect, how we use it, and your rights. ## Data We Collect ### Data stored locally on your device - **Bridge connection**: Bridge server URLs and device pairing tokens, stored encrypted via secure keychain - **Agent configurations**: Working directories and agent preferences, stored in the app's local database - **Session history**: Chat messages and tool call records from your AI agent sessions, stored locally in SQLite - **App preferences**: Theme settings and notification preferences, stored in local key-value storage ### Data we do NOT collect - We do not operate any servers or collect any telemetry by default - We do not transmit your code, files, or session data to any third party - We do not use advertising identifiers - We do not track your location ### Optional analytics (opt-in only) If you explicitly enable analytics in Settings, the app logs anonymized usage events locally. These events are never transmitted unless you configure a self-hosted analytics endpoint. ## Data Transmission ReCursor communicates only with: 1. **Your bridge server**: The app connects directly to the ReCursor bridge server running on your own machine via WebSocket. You control this server. 2. **Anthropic API**: If using the Agent SDK integration, requests are made via your bridge server using your own API key. ReCursor does not have access to your Anthropic API key. ## Security - Bridge pairing tokens are stored using iOS Keychain / Android Keystore via `flutter_secure_storage` - All bridge connections use WSS (TLS-encrypted WebSocket) - We recommend using Tailscale or WireGuard for bridge connectivity ## Your Rights You can delete all locally stored data by uninstalling the app or using "Reset App" in Settings. ## Changes We will update this policy as the app evolves. Check the app's GitHub repository for the latest version. ## Contact Questions? Open an issue at https://github.com/RecursiveDev/ReCursor/issues --- Route: /ReCursor/legal/terms-of-service/ Source: docs-site/src/content/docs/legal/terms-of-service.md Title: Terms of Service Description: **Last updated: 2026-03-17** **Last updated: 2026-03-17** ## Acceptance By using ReCursor, you agree to these terms. ## What ReCursor Is ReCursor is an open-source mobile companion app for AI coding agent workflows. It connects to a bridge server you run on your own machine. ## Your Responsibilities - You are responsible for the security of your bridge server - You are responsible for your Anthropic API key usage and costs - You must comply with Anthropic's usage policies when using the Agent SDK - Do not use ReCursor to automate actions you are not authorized to perform ## Disclaimer ReCursor is provided "as is" without warranty. The developers are not responsible for: - Code changes made by AI agents through the app - API costs incurred through Agent SDK usage - Data loss from local database corruption - Security issues arising from misconfigured bridge servers ## Open Source ReCursor is MIT-licensed. See LICENSE for details. ## Changes We may update these terms. Continued use constitutes acceptance.