DorkOS
Contributing

Architecture

Architecture overview for DorkOS contributors

Architecture

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

  1. Standalone web: Express server with HTTP/SSE communication
  2. Obsidian plugin: In-process services, no server or network required

This guide explains how the pieces fit together.

Monorepo Structure

DorkOS is a Turborepo monorepo. The apps and the packages that matter most for this guide:

The Transport Interface

The core abstraction is the Transport interface (packages/shared/src/transport.ts). It defines all client-server communication: session management, messaging, tool interaction, Tasks, Relay, Mesh, and agent identity.

A condensed view of the core methods (there is no separate createSession; posting a message with a fresh client-generated session id creates it on first send):

interface Transport {
  listSessions(cwd?)       → Promise<SessionListResponse>
  getSession(id, cwd?)     → Promise<Session>
  getMessages(sessionId, cwd?) → Promise<{ messages: HistoryMessage[] }>
  getSessionSnapshot(sessionId, cwd?) → Promise<SessionSnapshot>
  subscribeSession(sessionId, sinceCursor?, cwd?, signal?) → AsyncIterable<SessionEvent>
  subscribeSessionList()   → AsyncIterable<SessionListEvent>
  postMessage(sessionId, content, cwd?, options?) → Promise<{ sessionId: string }>
  // trigger-only (202); resolves to the canonical session id, which may
  // differ from the id passed in on session creation
  stopTask(sessionId, taskId) → Promise<{ success: boolean; taskId: string }>
  approveTool(sessionId, toolCallId, alwaysAllow?) → Promise<{ ok: boolean }>
  denyTool(sessionId, toolCallId) → Promise<{ ok: boolean }>
  getCommands(refresh?)    → Promise<CommandRegistry>
  getModels(opts?)         → Promise<ModelOption[]>
  getCapabilities()        → Promise<{ capabilities, defaultRuntime }>
  health()                 → Promise<HealthResponse>
}

The interface extends further to cover Tasks schedules, Relay messaging, Mesh agents, bindings, and admin operations. See packages/shared/src/transport.ts for the complete definition.

Two Transport Implementations

HttpTransport communicates with the Express server over HTTP:

  • Uses fetch() for CRUD operations
  • Triggers turns via postMessage() (POST /sessions/:id/messages, 202 with the canonical session id)
  • Session events arrive separately on the durable SSE streams (GET /sessions/:id/events, GET /events) owned by StreamManager

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

DirectTransport calls service instances directly in the same process:

  • No HTTP, no port binding, no network serialization
  • StreamManager iterates the runtime's snapshot + event streams in-process (getSessionSnapshot, subscribeSession, subscribeSessionList)
  • Lower latency, ideal 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 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 runtime = new ClaudeCodeRuntime(repoRoot)
const commandRegistry = new CommandRegistryService(repoRoot)

const transport = new DirectTransport({
runtime,
transcriptReader: runtime.getTranscriptReader(),
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

Routes are thin HTTP handlers. They obtain the active runtime via runtimeRegistry.getDefault(). The most load-bearing routes (not exhaustive; see apps/server/src/routes/ for the full list):

Prop

Type

Services

The services behind those routes, grouped by domain (also not exhaustive):

Prop

Type

Session Storage: Runtime-Owned

Sessions are not stored in a unified DorkOS database: each runtime owns its store, and GET /api/sessions aggregates across all registered runtimes, tagging every session with its runtime type. A failing runtime degrades to partial results plus a warning, never a failed request.

  • Claude Code: TranscriptReader scans SDK JSONL files at ~/.claude/projects/\{slug\}/*.jsonl. All sessions are visible regardless of which client created them (CLI, DorkOS, or otherwise); ClaudeCodeRuntime calls SDK query() with resume: sessionId for continuity across clients.
  • Codex: one DorkOS session per Codex SDK thread; the session-to-thread map lives in the codex_threads SQLite table.
  • OpenCode: sessions live in the sidecar's own store, read via @opencode-ai/sdk.
  • No delete endpoint: sessions persist in each runtime's storage.
  • A session's runtime is fixed when it starts (persisted first-write-wins in session_metadata).

Client Architecture

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

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/runtime/Runtime capabilitiesuseRuntimeCapabilities
entities/agent/Agent identity hooksuseCurrentAgent, useAgentVisual
entities/tasks/Task scheduler hooksuseTasksEnabled, useSchedules, useRuns
entities/relay/Relay messaging hooksuseRelayEnabled, useRelayMessages
entities/mesh/Mesh discovery hooksuseMeshEnabled, useRegisteredAgents
features/chat/Chat interfaceChatPanel, MessageList, ToolCallCard
features/session-list/Session managementSessionSidebar, SessionItem
features/settings/Settings UISettingsDialog
features/tasks/Task scheduler UITasksPanel, CreateScheduleDialog
features/relay/Relay messaging UIRelayPanel, AdapterCard
features/mesh/Mesh discovery UIMeshPanel, TopologyGraph
features/canvas/Agent-controlled UI panelsCanvas renderer, extension host
entities/marketplace/Marketplace data hooksuseMarketplacePackages, useInstallPackage, usePermissionPreview
features/marketplace/Marketplace browse and install UIMarketplace, PackageCard, PackageDetailSheet, InstallConfirmationDialog
widgets/marketplace/Marketplace pagesMarketplacePage (/marketplace), MarketplaceSourcesPage (/marketplace/sources)
widgets/app-layout/App-level layoutPermissionBanner

Layer imports are strictly unidirectional: sharedentitiesfeatureswidgetsapp. Cross-feature model/hook imports are forbidden.

State Management

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

Data Flow: Message from UI to Claude and Back

User types message

ChatPanel → useChatSession.handleSubmit()

transport.postMessage(sessionId, content, cwd) → POST /api/sessions/:id/messages

Express route validates, acquires the write-lock, starts the turn → 202 { sessionId }

Runtime yields StreamEvent objects → per-session projector assigns monotonic seq

GET /api/sessions/:id/events (durable SSE, already open via StreamManager)

StreamManager validates SessionEvent frames → session stream store applies them

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

ChatPanel → useChatSession.handleSubmit()

transport.postMessage(sessionId, content, cwd)

DirectTransport → runtime.sendMessage() → SDK query() (turn runs detached)

StreamManager's in-process pump iterates runtime.subscribeSession()

SessionEvents → session stream store applies them

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

SessionEvent Types

Runtimes yield internal StreamEvent objects; the per-session projector normalizes them into the runtime-neutral SessionEvent union (packages/shared/src/session-stream.ts), each carrying a monotonic seq:

Prop

Type

Session Event Stream

All session state is delivered on a durable per-session SSE stream: GET /api/sessions/:id/events. A cold connect receives a snapshot (history, in-progress turn, status, pending interactions, cursor), then live events. On reconnect, Last-Event-ID resumes from the last applied seq and the server replays the gap, or falls back to a fresh snapshot if it cannot. This one stream is also the multi-client sync mechanism: CLI writes and turns triggered by other clients appear in the DorkOS UI automatically.

The session list stays live via the global GET /api/events stream (session_upserted / session_removed / session_status), with no polling.

Testing

Tests use Vitest with vi.mock() for Node modules. 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 Design Properties

  • Same React app, two deployment modes: Transport abstraction enables full code reuse
  • No network required for Obsidian: DirectTransport runs entirely in-process
  • Type-safe end-to-end: Zod schemas generate both TypeScript types and OpenAPI specs
  • Testable: Mock Transport objects make testing React hooks and components straightforward
  • Real-time sync: durable, resumable SSE streams keep all clients in sync
  • Runtime-owned storage: each runtime keeps its own session store; DorkOS aggregates instead of duplicating (no unified transcript database)
  • Runtime-agnostic core: Claude Code, Codex, and OpenCode all implement the same AgentRuntime contract, proven by a shared conformance suite

Next Steps