DorkOSDorkOS
Contributing

Architecture

High-level architecture overview for DorkOS contributors

Architecture

DorkOS uses a hexagonal (ports & adapters) architecture that allows the same React client to run in two different modes:

  1. Standalone web — Express server + HTTP/SSE communication
  2. Obsidian plugin — In-process services, no server needed

This guide explains how the pieces fit together.

Monorepo Structure

DorkOS is organized as a Turborepo monorepo with npm workspaces:

Hexagonal Architecture: The Transport Interface

The core abstraction in DorkOS is the Transport interface (packages/shared/src/transport.ts). This interface defines 9 methods that handle all client-server communication:

interface Transport {
  createSession(opts)        → Session
  listSessions()             → Session[]
  getSession(id)             → Session
  getMessages(sessionId)     → { messages: HistoryMessage[] }
  sendMessage(id, content, onEvent, signal, cwd?) → void
  approveTool(sessionId, toolCallId)        → { ok: boolean }
  denyTool(sessionId, toolCallId)           → { ok: boolean }
  getCommands(refresh?)      → CommandRegistry
  health()                   → { status, version, uptime }
}

Two Transport Implementations

HttpTransport communicates with the Express server over HTTP:

  • Uses standard fetch() for CRUD operations
  • Parses Server-Sent Events (SSE) streams in sendMessage()
  • Converts SSE events into StreamEvent objects that update the UI

Location: apps/client/src/layers/shared/lib/http-transport.ts

DirectTransport calls service instances directly in the same process:

  • No HTTP, no port binding, no network serialization
  • Iterates AsyncGenerator<StreamEvent> from the Agent SDK
  • Much lower latency, perfect for embedded contexts

Location: apps/client/src/layers/shared/lib/direct-transport.ts

Both implementations expose the same interface, so the React client doesn't know (or care) which one it's using.

Dependency Injection via React Context

The Transport is injected into the React app via a Context provider:

// main.tsx
const transport = new HttpTransport({ baseUrl: '/api' })

<TransportProvider transport={transport}>
  <App />
</TransportProvider>
// CopilotView.tsx
const agentManager = new AgentManager(repoRoot)
const transcriptReader = new TranscriptReader()
const commandRegistry = new CommandRegistryService(repoRoot)

const transport = new DirectTransport({
  agentManager,
  transcriptReader,
  commandRegistry,
  vaultRoot: repoRoot
})

<TransportProvider transport={transport}>
  <ObsidianApp>
    <App />
  </ObsidianApp>
</TransportProvider>

Components and hooks access the transport via useTransport():

import { useTransport } from '@/layers/shared/model/TransportContext'

function MyComponent() {
  const transport = useTransport()
  const sessions = await transport.listSessions()
  // ...
}

Server Architecture

The Express server (apps/server/) is organized into routes and services:

Routes

Seven route groups handle REST/SSE endpoints:

Prop

Type

Services

Key services provide the core business logic:

Prop

Type

Session Storage: SDK JSONL Transcripts

Sessions are not stored in a database. The TranscriptReader scans SDK JSONL files at ~/.claude/projects/\{slug\}/*.jsonl. All sessions are visible regardless of which client created them.

  • Session ID = SDK session ID (UUID from filename)
  • No delete endpoint (sessions persist in SDK storage)
  • Session metadata (title, preview, timestamps) is extracted from file content on every request
  • The AgentManager calls the SDK's query() function with resume: sessionId for continuity across clients

Client Architecture

The React client (apps/client/) uses Feature-Sliced Design (FSD) architecture:

FSD Layers

LayerPurposeExamples
shared/ui/Reusable UI primitivesBadge, Dialog, Select, Tabs (shadcn)
shared/model/Hooks, stores, contextTransportContext, app-store, useTheme
shared/lib/Domain-agnostic utilitiescn(), font-config, celebrations
entities/session/Session domain hooksuseSessionId, useSessions
entities/command/Command domain hookuseCommands
features/chat/Chat interfaceChatPanel, MessageList, ToolCallCard
features/session-list/Session managementSessionSidebar, SessionItem
features/commands/Slash command paletteCommandPalette
features/settings/Settings UISettingsDialog
features/files/File browserFilePalette, useFiles
features/status/Status barStatusLine, GitStatusItem, ModelItem
widgets/app-layout/App-level layout componentsPermissionBanner

Layers follow a strict dependency rule: sharedentitiesfeatureswidgetsapp (unidirectional only). Cross-feature model/hook imports are forbidden.

State Management

  • Zustand for UI state (sidebar open/closed, theme, etc.) — layers/shared/model/app-store.ts
  • TanStack Query for server state (sessions, messages, commands) — entities/session/, entities/command/
  • URL Parameters (standalone mode) — ?session= and ?dir= persist state in the URL for bookmarking and sharing

Markdown Rendering

Assistant messages are rendered as rich markdown via the streamdown library (from Vercel). The StreamingText component wraps <Streamdown> with syntax highlighting (Shiki) and shows a blinking cursor during active streaming. User messages remain plain text.

Data Flow: Message from UI to Claude and Back

User types message

ChatPanel → useChatSession.handleSubmit()

transport.sendMessage(sessionId, content, onEvent, signal, cwd)

fetch(POST /api/sessions/:id/messages) + ReadableStream SSE parsing

Express route → AgentManager.sendMessage() → SDK query()

SDK yields StreamEvent objects → SSE wire format

HttpTransport parses SSE → calls onEvent(event)

React state updates → UI re-renders with new message chunks
User types message

ChatPanel → useChatSession.handleSubmit()

transport.sendMessage(sessionId, content, onEvent, signal, cwd)

DirectTransport → agentManager.sendMessage() → SDK query()

SDK yields AsyncGenerator<StreamEvent>

DirectTransport iterates generator → calls onEvent(event)

React state updates → UI re-renders with new message chunks

StreamEvent Types

Events flowing from the SDK to the UI include:

Prop

Type

Session Sync Protocol

Clients can subscribe to real-time session changes via GET /api/sessions/:id/stream (persistent SSE connection). This enables multi-client sync (e.g., CLI writes then DorkOS UI updates automatically).

Events:

  • sync_connected — Sent on initial connection. Data: { sessionId }
  • sync_update — Sent when new content is written to the session's JSONL file. Data: { sessionId, timestamp }

Clients receiving sync_update should re-fetch message history. The GET /messages endpoint supports ETag caching for efficient polling.

Module Layout

transport.ts
types.ts
schemas.ts
config-schema.ts

Testing

Tests use Vitest with vi.mock() for Node modules. All client tests inject mock Transport objects via TransportProvider:

import { createMockTransport } from '@dorkos/test-utils'

const mockTransport = createMockTransport({
  listSessions: vi.fn().mockResolvedValue([]),
  sendMessage: vi.fn(),
})

function Wrapper({ children }: { children: React.ReactNode }) {
  return (
    <TransportProvider transport={mockTransport}>
      {children}
    </TransportProvider>
  )
}

render(<MyComponent />, { wrapper: Wrapper })

This pattern provides type safety and explicit test setup without global mocks.

Key Architectural Benefits

  • Same React app runs standalone and embedded — Transport abstraction enables code reuse
  • Server-optional — Obsidian plugin has zero network latency
  • Type-safe — Zod schemas generate both TypeScript types and OpenAPI specs
  • Testable — Mock Transport objects make testing React hooks and components straightforward
  • Real-time sync — SSE streaming and file watching keep all clients in sync
  • SDK-first — JSONL transcripts are the single source of truth (no separate database)

Next Steps