rait-09_obsidian-agent-client/ARCHITECTURE.md

25 KiB

Architecture Documentation

Overview

Obsidian plugin for AI agent interaction via ACP. useAgent facade hook composes sub-hooks (useAgentSession + useAgentMessages) and subscribes to a single onSessionUpdate channel. ChatPanel orchestrates hooks and renders children directly. Services are injected via React Context. ACP protocol details are isolated in the acp/ layer.

Directory Structure

src/
├── types/                          # Type Definitions (no logic, no dependencies)
│   ├── chat.ts                     # ChatMessage, MessageContent, PromptContent, AttachedFile, ActivePermission
│   ├── session.ts                  # ChatSession, SessionUpdate (12-type union), SessionInfo, Capabilities
│   ├── agent.ts                    # AgentConfig, agent settings (preset/custom)
│   ├── errors.ts                   # AcpError, ProcessError, ErrorInfo
│   └── obsidian-internals.d.ts     # Type augmentation for unofficial Obsidian APIs
│
├── acp/                            # ACP Protocol Layer (SDK dependency confined here)
│   ├── acp-client.ts               # Process lifecycle, UI-facing API (AcpClient class)
│   ├── acp-handler.ts              # SDK event handler + sessionId filter + listener broadcast
│   ├── type-converter.ts           # ACP SDK types ↔ internal types
│   ├── permission-handler.ts       # Permission queue, auto-approve, Promise resolution
│   └── terminal-handler.ts         # Terminal process create/output/kill
│
├── services/                       # Business Logic (non-React, no React imports)
│   ├── vault-service.ts            # Vault access + fuzzy search + CM6 selection tracking
│   ├── settings-service.ts         # Reactive settings store (observer pattern only)
│   ├── session-storage.ts          # Session metadata + message file I/O (sessions/*.json)
│   ├── preset-agents.ts            # Static registry of preset (built-in) agents (PRESET_AGENTS)
│   ├── settings-normalizer.ts      # Settings validation helpers (str, bool, num, enumVal, etc.)
│   ├── session-helpers.ts          # Agent enumeration/resolution, enabled/default policy, API key injection (pure functions)
│   ├── session-state.ts            # Session state updates (legacy mode/model, config restore)
│   ├── message-state.ts            # Message array transforms (upsert, merge, streaming apply)
│   ├── message-sender.ts           # Prompt preparation + sending (pure functions)
│   ├── chat-exporter.ts            # Markdown export with frontmatter
│   ├── view-registry.ts            # Multi-view management, focus, broadcast
│   └── update-checker.ts           # Agent + plugin version checking (checkAgentUpdate / checkPluginUpdate)
│
├── hooks/                          # React Custom Hooks (state + logic)
│   ├── useAgent.ts                 # Facade: composes useAgentSession + useAgentMessages
│   ├── useAgentSession.ts          # Session lifecycle, config options, optimistic updates
│   ├── useAgentMessages.ts         # Message state, streaming (RAF batch), permissions
│   ├── useSuggestions.ts           # @[[note]] mentions + /command suggestions (unified)
│   ├── useSessionHistory.ts        # Session list/load/resume/fork, 5-min cache
│   ├── useChatActions.ts           # Business callbacks (send, newChat, export, restart, etc.)
│   ├── useHistoryModal.ts          # Session history modal lifecycle
│   └── useSettings.ts              # Settings subscription (useSettings + useSettingsSelector, useSyncExternalStore)
│
├── ui/                             # React Components
│   ├── ChatContext.ts              # React Context (plugin, acpClient, vaultService, settingsService)
│   ├── ChatPanel.tsx               # Orchestrator: calls hooks, workspace events, rendering
│   ├── ChatView.tsx                # Sidebar view (ItemView + Context Provider)
│   ├── FloatingChatView.tsx        # Floating window (position/drag/resize + Context Provider)
│   ├── CodeBlockChatView.tsx       # Embedded chat in markdown code blocks (```agent-client / ```agent)
│   ├── AgentButtonBlock.tsx        # Quick-action button block (type: button → runPromptInChat)
│   ├── SessionManagerView.tsx      # Session manager view (observes open chat views, not a chat itself)
│   ├── FloatingButton.tsx          # Draggable launch button
│   ├── ChatHeader.tsx              # Header (sidebar + floating + embedded variants)
│   ├── MessageList.tsx             # Virtualized message list (@tanstack/react-virtual)
│   ├── MessageBubble.tsx           # Single message (content dispatch, copy button)
│   ├── ToolCallBlock.tsx           # Tool call display + diff (word-level highlighting) + collapsible text output panel
│   ├── TerminalBlock.tsx           # Terminal output polling
│   ├── InputArea.tsx               # Textarea, attachments, mentions, history
│   ├── InputToolbar.tsx            # Config/mode/model selectors, usage, send button
│   ├── SuggestionPopup.tsx         # Mention/command dropdown
│   ├── PermissionBanner.tsx        # Permission request buttons
│   ├── ErrorBanner.tsx             # Error/notification overlay
│   ├── SessionHistoryModal.tsx     # Session history modal (list + confirm delete)
│   ├── EditTitleModal.ts           # Session title rename modal
│   ├── ChangeDirectoryModal.ts     # Working directory picker modal (Electron dialog)
│   ├── SettingsTab.ts              # Plugin settings UI
│   ├── view-host.ts                # IChatViewHost interface
│   └── shared/
│       ├── IconButton.tsx           # Icon button + Lucide icon wrapper
│       ├── MarkdownRenderer.tsx     # Obsidian markdown rendering
│       └── AttachmentStrip.tsx      # Attachment preview strip
│
├── utils/                          # Shared Utilities (mostly pure; some Obsidian/Node coupling)
│   ├── platform.ts                 # Shell, WSL, Windows env, command building (execSync + Obsidian Platform)
│   ├── paths.ts                    # Path resolution, file:// URI
│   ├── error-utils.ts              # ACP error conversion
│   ├── mention-parser.ts           # @[[note]] detection/extraction
│   ├── agent-block-parser.ts       # YAML parser for agent-client/agent code blocks (chat | button)
│   ├── wikilink-resolver.ts        # Wikilink resolution via Obsidian metadata cache (App-dependent)
│   ├── wikilink-formatter.ts       # <obsidian_note_links> block formatting (pure)
│   ├── text.ts                     # Text helpers (truncateTitle)
│   └── logger.ts                   # Debug-mode logger
│
├── plugin.ts                       # Obsidian plugin lifecycle, commands, view management, AcpClient pool, code block processors
└── main.ts                         # Entry point (re-exports plugin)

Architectural Layers

1. Types Layer (src/types/)

Purpose: Pure type definitions. No logic, no dependencies.

File Contents
chat.ts ChatMessage, MessageContent (8+ type union), Role, ToolCallStatus, ToolKind, AttachedFile, ActivePermission, PromptContent
session.ts ChatSession, SessionState, SessionUpdate (12-type union incl. ProcessErrorUpdate), SessionConfigOption, Capabilities, SessionInfo
agent.ts AgentEnvVar, BaseAgentSettings, PresetAgentUserSettings, CustomAgentSettings
errors.ts AcpErrorCode, AcpError, ProcessError, ErrorInfo

2. ACP Layer (src/acp/)

Purpose: Isolate ACP protocol dependency. All @agentclientprotocol/sdk imports are confined here.

File Purpose
acp-client.ts UI-facing API: process spawn/kill, JSON-RPC communication, session management. Owns AcpHandler + managers. Single exit point: onSessionUpdate (multiple listeners via Set).
acp-handler.ts SDK-facing: receives sessionUpdate, requestPermission, terminal ops. Filters by currentSessionId (when it is null, ALL updates pass through — spawn-failure process_error arrives with an empty sessionId). Broadcasts to all listeners.
type-converter.ts Converts ACP SDK types to internal types (change buffer for protocol updates)
permission-handler.ts Permission request queue, auto-approve, Promise-based resolution. All UI updates via onSessionUpdate (no separate callback path).
terminal-handler.ts Terminal process create/output/kill, stdout/stderr buffering

Key design: All agent events (messages, session updates, permissions, errors) flow through a single onSessionUpdate channel. No special paths.


3. Services Layer (src/services/)

Purpose: Non-React business logic. Classes and pure functions. No React imports.

File Purpose
vault-service.ts VaultService class — vault note access, fuzzy search, CM6 selection tracking. Exports IVaultAccess, NoteMetadata.
settings-service.ts SettingsService class — reactive settings store (observer pattern). Delegates session storage to SessionStorage. Exports ISettingsAccess.
session-storage.ts SessionStorage class — session metadata CRUD (in plugin settings) + message file I/O (sessions/*.json).
settings-normalizer.ts Pure functions — settings validation helpers (str, bool, num, enumVal, obj, strRecord, xyPoint), toAgentConfig, parseChatFontSize.
preset-agents.ts Static registry (PRESET_AGENTS) of preset agent definitions — identity, spawn defaults, API-key wiring, install hints, settings-UI copy
session-helpers.ts Pure functions — agent config building, API key injection, agent enumeration/resolution, enabled/default-agent policy (isAgentEnabled, firstEnabledAgentId, repairNoEnabledAgents, getDefaultAgentId, getCurrentAgent), computeSessionTitle, Gemini deprecation notice builder
session-state.ts Pure functions — legacy mode/model application, config option restoration
message-state.ts Pure functions — message array transforms (streaming apply, tool call upsert with O(1) index, permission scanning)
message-sender.ts Pure functions — prompt preparation (embedded context vs XML text, shared helpers), sending with auth retry
chat-exporter.ts ChatExporter class — markdown export with frontmatter, image handling
view-registry.ts ChatViewRegistry class — multi-view focus tracking, broadcast commands. Exports IChatViewContainer.
update-checker.ts Agent version checking via npm registry + plugin self-update check (checkPluginUpdate)

4. Hooks Layer (src/hooks/)

Purpose: React state management. Hook composition via useAgent facade.

Hook Responsibility
useAgent Facade: composes useAgentSession + useAgentMessages. Single onSessionUpdate subscription. Return is useMemo-wrapped.
useAgentSession Session lifecycle (create/close/restart), mode/configOption with optimistic updates (no setModel — model selection goes through the category "model" config option). Uses sessionRef pattern.
useAgentMessages Message state, RAF-batched streaming, permissions (activePermission derivation, approve/reject)
useSuggestions @note mentions + /command suggestions (unified). Return is useMemo-wrapped.
useSessionHistory Session list/load/resume/fork, local session storage, 5-min cache. Restore prefers locally saved messages; agent history replay is the fallback. Return is useMemo-wrapped.
useChatActions Business callbacks (send, newChat, export, restart, config changes). Individual method deps for stability.
useHistoryModal Session history modal lifecycle (lazy creation, props sync)
useSettings Settings subscription via useSyncExternalStore. Also exports useSettingsSelector (slice subscription; used by ChatPanel)

Dependency Rule: Hooks import from types/, acp/, services/, utils/. Never from ui/.


5. UI Layer (src/ui/)

Purpose: React components. Rendering and user interaction.

Core Architecture

ChatContext provides shared services to the component tree:

interface ChatContextValue {
  plugin: AgentClientPlugin;
  acpClient: AcpClient;
  vaultService: VaultService;
  settingsService: SettingsService;
}

ChatPanel is the central orchestrator:

  • Calls hooks: useAgent, useSuggestions, useSessionHistory, useChatActions, useHistoryModal, useSettings
  • Does NOT route session updates (useAgent handles that internally)
  • Handles workspace events via ref pattern (stable event registration)
  • Renders ChatHeader, MessageList, InputArea directly

ChatView (sidebar), FloatingChatView (floating window), and CodeBlockChatView (embedded in markdown code blocks) are thin wrappers:

  • Obtain AcpClient from the plugin-owned pool (plugin.getOrCreateAcpClient(viewId)); create VaultService per view
  • Provide ChatContext
  • Render ChatPanel with variant prop ("sidebar" | "floating" | "embedded")
  • Implement IChatViewContainer for broadcast commands

AcpClient instances are owned by the plugin (plugin._acpClients: Map<viewId, AcpClient>), not by views. Embedded views call acquireAcpClient/releaseAcpClient; release schedules teardown with a 250ms grace window so markdown re-renders keep the agent process alive.

Embedded chat & agent button: plugin.ts registers markdown code block processors for the languages agent-client and agent (renderAgentBlock). The fence body is YAML (utils/agent-block-parser.ts), dispatched by type: chat (default) mounts CodeBlockChatView — ensureEmbedId writes a stable id back into the block so the session can be re-associated via getSavedSessionByEmbedId; button mounts AgentButtonBlock, which calls plugin.runPromptInChat to open/focus a chat view and deliver the prompt (queued if the panel has not mounted yet).

Component Tree

ChatView / FloatingChatView / CodeBlockChatView
  └── ChatContextProvider
        └── ChatPanel (variant="sidebar" | "floating" | "embedded")
              ├── ChatHeader (variant-based rendering)
              ├── MessageList (virtualized via @tanstack/react-virtual)
              │     └── MessageBubble (per message, React.memo)
              │           ├── ToolCallBlock (React.memo) → PermissionBanner
              │           ├── TerminalBlock (React.memo)
              │           └── MarkdownRenderer
              ├── InputArea
              │     ├── SuggestionPopup (mentions / commands)
              │     ├── ErrorBanner
              │     ├── AttachmentStrip
              │     └── InputToolbar (config/mode/model/usage/send)
              └── SessionHistoryModal (imperative, via useHistoryModal)

6. Utils Layer (src/utils/)

Purpose: Shared utility functions. No React. Mostly pure, but some files carry Obsidian/Node coupling (noted below).

File Purpose
platform.ts Shell detection, WSL path conversion, Windows PATH from registry, platform-specific command preparation (uses execSync and Obsidian's Platform)
paths.ts Path resolution (which/where), file:// URI building, relative path conversion
error-utils.ts ACP error code → user-friendly title/suggestion conversion
mention-parser.ts @note detection, replacement, extraction from text
agent-block-parser.ts YAML parser for agent-client/agent code blocks (chat | button; uses Obsidian's parseYaml only)
wikilink-resolver.ts Wikilink resolution via Obsidian's metadata cache (depends on App)
wikilink-formatter.ts <obsidian_note_links> context block formatting (pure)
text.ts Text helpers (truncateTitle)
logger.ts Singleton logger respecting debugMode setting

Dependency Flow

┌─────────────────────────────────────────────────────────────┐
│                         UI Layer                             │
│                                                              │
│  ChatView / FloatingChatView (Context Providers)             │
│    └── ChatPanel (hook composition + rendering)              │
│          ├── ChatHeader, MessageList, InputArea              │
│          └── MessageBubble, ToolCallBlock, etc.              │
└─────────────────────────────┬───────────────────────────────┘
                              ↓ calls hooks
┌─────────────────────────────┴───────────────────────────────┐
│                       Hooks Layer                            │
│  useAgent (facade) → useAgentSession + useAgentMessages      │
│  useSuggestions, useSessionHistory, useChatActions,           │
│  useHistoryModal, useSettings                                │
└───────────┬─────────────────────────────┬───────────────────┘
            ↓ calls                       ↓ reads types
┌───────────┴───────────┐   ┌─────────────┴───────────────────┐
│   Services Layer      │   │        Types Layer               │
│   VaultService        │   │   chat.ts, session.ts,           │
│   SettingsService     │   │   agent.ts, errors.ts            │
│   SessionStorage      │   └─────────────────────────────────┘
│   settings-normalizer │
│   session-helpers      │
│   session-state       │
│   message-state       │
│   message-sender      │
│   chat-exporter       │
│   view-registry       │
└───────────┬───────────┘
            ↓ communicates
┌───────────┴───────────┐
│     ACP Layer         │
│   acp-client.ts       │
│   acp-handler.ts      │
│   type-converter.ts   │
│   permission-handler  │
│   terminal-handler    │
└───────────────────────┘
            ↑
    @agentclientprotocol/sdk

Design Patterns

1. useAgent Facade Pattern

  • useAgent composes useAgentSession + useAgentMessages
  • Single onSessionUpdate subscription, dispatches to both sub-hooks
  • ChatPanel calls useAgent, not sub-hooks directly
  • Return is useMemo-wrapped for referential stability

2. React Context for Services

  • ChatContext provides plugin, acpClient, vaultService, settingsService
  • Value is stable (service instances don't change)
  • Eliminates prop drilling for shared dependencies

3. Single Event Channel

  • All agent events flow through onSessionUpdate (messages, session updates, permissions, errors)
  • No special callback paths (onUpdateMessage, onError removed)
  • AcpHandler filters by currentSessionId before broadcasting

4. ACP Isolation

  • All @agentclientprotocol/sdk imports confined to acp/
  • AcpClient (UI-facing) and AcpHandler (SDK-facing) separate concerns
  • type-converter.ts is the change buffer for protocol updates

5. Performance Patterns

  • useMemo for return stability: useAgent, useSuggestions, useSessionHistory wrap returns in useMemo
  • sessionRef pattern: useAgentSession stores session in useRef, reads in callbacks without adding to deps
  • Individual method deps: useChatActions uses agent.sendMessage not agent object in deps
  • Workspace event refs: ChatPanel stores handler callbacks in refs, keeping useEffect deps minimal
  • RAF batching: useAgentMessages batches streaming updates per animation frame
  • React.memo: MessageBubble, ToolCallBlock, TerminalBlock for skip-render optimization
  • Virtual scroll: MessageList uses @tanstack/react-virtual
  • O(1) tool call index: Map<string, number> for tool call upsert

6. Observer Pattern

  • SettingsService notifies subscribers on change
  • React components use useSyncExternalStore

7. Ref Pattern for Callbacks

  • IChatViewContainer callbacks use refs for latest values
  • Workspace event handlers use refs to avoid re-registration
  • Unmount cleanup uses refs to access latest state

Key Benefits

1. Flat and Readable

  • 4 layers (types → acp/services → hooks → ui)
  • No port/adapter indirection
  • File names reflect functionality

2. ACP Change Resistance

  • Only acp/ directory needs changes for protocol updates
  • type-converter.ts localizes type mapping changes

3. Easy Feature Addition

  • New hook: create in hooks/, call in ChatPanel, wrap return in useMemo
  • New message type: add to types/session.ts, handle in useAgentMessages or message-state.ts, render in MessageBubble
  • New preset agent: add one entry to PRESET_AGENTS in services/preset-agents.ts (registry-driven)

4. Maintainability

  • ~23,100 lines across 67 files
  • Services testable without React (zero React imports)
  • Clear dependency direction (no circular dependencies)

File Naming Conventions

Pattern Example
Types kebab-case.ts in types/
ACP kebab-case.ts in acp/
Services kebab-case.ts in services/
Hooks use*.ts in hooks/
Components PascalCase.tsx in ui/
Utilities kebab-case.ts in utils/

Adding New Features

Adding a New Hook

  1. Create hooks/use[Feature].ts
  2. Define state with useState/useReducer
  3. Call the hook in ui/ChatPanel.tsx
  4. Pass state/callbacks to child components as props
  5. Wrap return object in useMemo if passed as dependency to other hooks

Adding a New Session Update Type

  1. Add interface to types/session.ts, add to SessionUpdate union
  2. Handle in acp/acp-handler.ts sessionUpdate() switch
  3. Convert from ACP type in acp/type-converter.ts if needed
  4. Handle in hooks/useAgentSession.ts handleSessionUpdate() (for session-level)
  5. Or handle via applySingleUpdate() in services/message-state.ts (for message-level)
  6. No routing needed in ChatPanel — useAgent handles dispatch internally

Adding a New Preset Agent

  1. Add one entry to PRESET_AGENTS in services/preset-agents.ts (presetId, defaults, optional API-key wiring, install hint, settings copy). Settings storage, enumeration, API key injection, and the settings UI are all registry-driven — no per-agent code elsewhere.
  2. Add the documentation pages (see the "Add Preset Agent" checklist in CLAUDE.md for the full file list).

Migration Notes

March 2026: Simplified Architecture Refactoring

Refactored from Port/Adapter Architecture to simplified layered architecture:

  • Removed: domain/models/ (9 files → types/ 4 files), domain/ports/ (5 files → interfaces moved to implementation files), adapters/ directory, components/ directory, shared/ directory
  • Added: types/, acp/, services/, ui/, utils/ flat directories, ChatPanel + ChatContext
  • Merged: VaultAdapter + MentionService → VaultService, useMentions + useAutoMention → useMentions
  • Removed: useChatController (god hook → ChatPanel component), Port files (no implementation swapping planned)
  • Result: 76 → 50 files, 5 → 4 layers, flat directory structure

April 2026: Simplification & Performance Refactoring

Refactored data flow, hooks, services, and performance:

  • ACP wiring: 3 exit points (onSessionUpdate, onError, setUpdateMessageCallback) → 1 (onSessionUpdate only). Multiple listeners via Set. SessionId filter in AcpHandler.
  • Hook consolidation: 7 hooks → 4 public hooks. useSession + useMessages + usePermission → useAgent (facade) + useAgentSession + useAgentMessages. useMentions + useSlashCommands → useSuggestions. New: useChatActions, useHistoryModal.
  • ChatPanel slimmed: 1,483 → 936 lines. Session update routing removed (moved to useAgent). Business callbacks extracted to useChatActions. History modal extracted to useHistoryModal. Workspace events stabilized with refs.
  • Services split: settings-service.ts (722 lines) → settings-service (285) + session-storage (267) + settings-normalizer (264). Pure functions extracted: message-state.ts, session-state.ts.
  • plugin.ts cleaned: loadSettings compressed with helper functions (370 → 120 lines). Legacy floatingChatInstances removed. Double-save fixed.
  • Performance: useMemo on hook returns (useAgent, useSuggestions, useSessionHistory). sessionRef pattern in useAgentSession. Individual method deps in useChatActions. Workspace event handler refs in ChatPanel.
  • Result: 50 → 56 files, ~19,800 lines. Single event channel. All hooks stabilized.