Skip to main content

Overview

The main process (src/main/) is the Node.js backend of Emdash. It has full access to the filesystem, system APIs, and native modules. The renderer communicates with it exclusively through IPC (Inter-Process Communication). Key responsibilities:
  • Manage application lifecycle and native OS integration
  • Handle all file system operations and git commands
  • Spawn and manage PTY (pseudo-terminal) sessions for CLI agents
  • Maintain SQLite database with Drizzle ORM
  • Orchestrate worktree creation and pooling
  • Manage SSH connections for remote development
  • Expose secure IPC handlers for renderer

Core services

All services follow a singleton pattern:
export class ExampleService {
  private someState: Map<string, Data>;
  
  async doSomething(args: SomeArgs): Promise<Result> {
    // Implementation
  }
}

// Module-level singleton export
export const exampleService = new ExampleService();
This ensures only one instance exists across the entire main process.

DatabaseService

src/main/services/DatabaseService.ts - All SQLite CRUD operations using Drizzle ORM. Schema: Defined in src/main/db/schema.ts:
export const projects = sqliteTable('projects', {
  id: text('id').primaryKey(),
  name: text('name').notNull(),
  path: text('path').notNull(),
  isRemote: integer('is_remote', { mode: 'boolean' }).default(false),
  sshConnectionId: text('ssh_connection_id'),
  gitInfo: text('git_info', { mode: 'json' }),
  createdAt: text('created_at').notNull(),
  updatedAt: text('updated_at').notNull(),
});

export const tasks = sqliteTable('tasks', {
  id: text('id').primaryKey(),
  projectId: text('project_id').notNull(),
  name: text('name').notNull(),
  branch: text('branch').notNull(),
  path: text('path').notNull(),
  status: text('status').notNull(), // 'active' | 'idle' | 'running'
  archivedAt: text('archived_at'),
  createdAt: text('created_at').notNull(),
  // ...
});

export const conversations = sqliteTable('conversations', {
  id: text('id').primaryKey(),
  taskId: text('task_id').notNull(),
  title: text('title').notNull(),
  provider: text('provider'), // Which agent (claude, codex, etc.)
  isMain: integer('is_main', { mode: 'boolean' }).default(false),
  displayOrder: integer('display_order').default(0),
  // ...
});
Database locations:
  • macOS: ~/Library/Application Support/emdash/emdash.db
  • Linux: ~/.config/emdash/emdash.db
  • Windows: %APPDATA%\emdash\emdash.db
  • Override: Set EMDASH_DB_FILE environment variable
Key methods:
// Projects
await databaseService.getProjects(); // All projects
await databaseService.getProject(id); // Single project
await databaseService.createProject({ name, path, gitInfo });
await databaseService.updateProject(id, updates);

// Tasks
await databaseService.getTasks(projectId);
await databaseService.createTask({ projectId, name, branch, path });
await databaseService.updateTask(id, { status: 'running' });
await databaseService.archiveTask(id); // Soft delete

// Conversations (multi-chat support)
await databaseService.getConversations(taskId);
await databaseService.createConversation({ taskId, title, provider });
Migrations: Generated by Drizzle Kit
# After modifying schema.ts:
pnpm exec drizzle-kit generate  # Creates migration SQL
pnpm exec drizzle-kit studio    # Browse database GUI
Never manually edit drizzle/meta/ or numbered migration files.

WorktreeService

src/main/services/WorktreeService.ts - Git worktree lifecycle and file preservation. What are worktrees? Each task runs in an isolated git worktree, allowing multiple agents to work on different branches simultaneously without conflicts. Worktree creation:
const result = await worktreeService.createWorktree(
  '/Users/dev/myapp',           // Main repo path
  'fix-auth-bug',               // Task name
  'my-username'                 // User identifier
);

// Creates:
// Path: /Users/dev/worktrees/fix-auth-bug-a3f/
// Branch: emdash/fix-auth-bug-a3f
Naming scheme:
  • Directory: ../worktrees/{slugged-name}-{3-char-hash}/
  • Branch: {prefix}/{slugged-name}-{hash}
  • Prefix defaults to emdash, configurable in settings
File preservation: Gitignored files copied from main repo to worktree. Default patterns (always preserved):
const DEFAULT_PRESERVE_PATTERNS = [
  '.env',
  '.env.keys',
  '.env.local',
  '.env.*.local',
  '.envrc',
  'docker-compose.override.yml',
];
Custom patterns via .emdash.json at project root:
{
  "preservePatterns": [
    ".claude/**",
    ".codex/**",
    "local-config.yaml"
  ]
}
Key methods:
// Create worktree
await worktreeService.createWorktree(projectPath, taskName, userId);

// Remove worktree
await worktreeService.removeWorktree(projectPath, worktreePath);

// List all worktrees
const list = await worktreeService.listWorktrees(projectPath);
// Returns: [{ path: '...', branch: '...', head: '...' }]

WorktreePoolService

src/main/services/WorktreePoolService.ts - Eliminates 3-7 second worktree creation delay. How it works:
  1. Pre-creation: When a project opens, creates a reserve worktree in background:
    ../worktrees/_reserve/{random-hash}/
    
  2. Instant claim: When user creates a task, instantly renames the reserve:
    git worktree move _reserve/abc123 fix-auth-bug-a3f
    git branch -m _reserve/abc123 emdash/fix-auth-bug-a3f
    
  3. Replenish: Creates a new reserve in the background
  4. Cleanup: Reserves expire after 30 minutes, cleaned on app startup
Result: Task creation is nearly instant (< 100ms) instead of 3-7 seconds.

ptyManager

src/main/services/ptyManager.ts - PTY lifecycle, agent spawning, session isolation. What is PTY? A pseudo-terminal that emulates a terminal session. Agents like claude, codex, amp run inside PTYs, streaming output back to the UI. Three spawn modes:

1. Shell-based spawn (startPty)

Wraps the agent in a shell for post-exit shell access:
const cmd = `${cli} ${args}; exec ${shell} -il`;
// Example: "claude --dangerously-skip-permissions -i 'Fix login'; exec /bin/zsh -il"

const pty = spawn(shell, ['-c', cmd], {
  cwd: '/path/to/worktree',
  env: minimalEnv, // Minimal env, not process.env
  cols: 120,
  rows: 40,
});
After the agent exits, user gets a shell in the worktree directory.

2. Direct spawn (startDirectPty)

Spawns the agent binary directly without shell wrapper:
const pty = spawn(cliPath, args, {
  cwd: '/path/to/worktree',
  env: minimalEnv,
  cols: 120,
  rows: 40,
});
Faster startup. Falls back to shell-based if:
  • CLI path isn’t cached
  • Project has shellSetup in .emdash.json

3. SSH spawn (startSshPty)

For remote development:
const pty = spawn('ssh', ['-tt', 'user@host', 'cd /remote/path && agent ...'], {
  env: sshEnv,
  cols: 120,
  rows: 40,
});
Environment variables: PTYs use minimal environment, not process.env. The definitive passthrough list:
const AGENT_ENV_VARS = [
  'ANTHROPIC_API_KEY',
  'OPENAI_API_KEY',
  'AWS_ACCESS_KEY_ID',
  'AWS_SECRET_ACCESS_KEY',
  'GITHUB_TOKEN',
  'GH_TOKEN',
  'GOOGLE_API_KEY',
  // ... 40+ more
];

const minimalEnv = {
  PATH: process.env.PATH,
  HOME: process.env.HOME,
  USER: process.env.USER,
  TERM: 'xterm-256color',
  // Only pass through API keys from AGENT_ENV_VARS list
  ...(process.env.ANTHROPIC_API_KEY && { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY }),
  // ...
};
To add support for a new agent’s API key, add it to AGENT_ENV_VARS. Session isolation: For Claude Code, each conversation gets a unique session ID:
// Generate deterministic UUID from conversation ID
const sessionId = crypto
  .createHash('sha256')
  .update(`emdash-${conversationId}`)
  .digest('hex')
  .slice(0, 32);

// Launch with session isolation
const args = ['--session-id', sessionId, '--resume', ...otherArgs];
Session map persisted to {userData}/pty-session-map.json. PTY ID format (src/shared/ptyId.ts):
{providerId}-main-{taskId}        # Main conversation
{providerId}-chat-{conversationId} # Additional chat tabs
Examples:
  • claude-main-task-abc123
  • codex-chat-conv-def456
Data streaming: PTY output flushed to renderer every 16ms:
pty.onData((data: string) => {
  const win = BrowserWindow.getAllWindows()[0];
  win?.webContents.send(`pty:data:${ptyId}`, data);
});

Other key services

GitService (src/main/services/GitService.ts)
  • Git operations: status, diff, commit, push, branch management
  • Uses simple-git library
GitHubService (src/main/services/GitHubService.ts)
  • GitHub operations via gh CLI
  • PR creation, check runs, repository info
SkillsService (src/main/services/SkillsService.ts)
  • Agent Skills standard implementation
  • Cross-agent skill packages with YAML frontmatter
  • Central storage: ~/.agentskills/{skill-name}/
  • Symlinks to agent-native directories: ~/.claude/commands/, ~/.codex/skills/
SshService (src/main/services/ssh/SshService.ts)
  • SSH connection management
  • Password, key, and agent authentication
  • Credentials stored in OS keychain via keytar
TaskLifecycleService (src/main/services/TaskLifecycleService.ts)
  • Lifecycle script orchestration
  • Runs scripts from .emdash.json:
    {
      "shellSetup": "source .envrc && nvm use"
    }
    

IPC handler pattern

All services expose functionality via IPC handlers. See IPC handlers for details. Example service with IPC:
// src/main/services/ExampleService.ts
export class ExampleService {
  async processData(input: string): Promise<{ result: string }> {
    // Heavy computation here
    return { result: input.toUpperCase() };
  }
}

export const exampleService = new ExampleService();

// src/main/services/exampleIpc.ts
import { ipcMain } from 'electron';
import { exampleService } from './ExampleService';

export function registerExampleIpc() {
  ipcMain.handle('example:process', async (_event, input: string) => {
    try {
      const data = await exampleService.processData(input);
      return { success: true, data };
    } catch (error) {
      return { success: false, error: error.message };
    }
  });
}
All handlers return { success: boolean, data?: any, error?: string }.

Error handling

Main process uses structured logging:
import { log } from '../lib/logger';

try {
  await someOperation();
  log.info('Operation completed', { taskId, duration });
} catch (error) {
  log.error('Operation failed', { taskId, error });
  throw error;
}
Logger outputs to:
  • Console (during development)
  • Log files: {userData}/logs/main.log (production)

Testing

Services tested with Vitest using mocks:
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { WorktreeService } from '../WorktreeService';

// Mock dependencies
vi.mock('child_process', () => ({
  execFile: vi.fn(),
}));

vi.mock('../lib/logger', () => ({
  log: { info: vi.fn(), error: vi.fn() },
}));

describe('WorktreeService', () => {
  it('creates worktree with correct naming', async () => {
    const service = new WorktreeService();
    const result = await service.createWorktree('/repo', 'my-task', 'user');
    expect(result.path).toMatch(/worktrees\/my-task-[a-z0-9]{3}/);
  });
});
Tests located in src/test/main/ (15 service tests).

Next steps

Build docs developers (and LLMs) love