Emdash Documentation

repository·main·Indexed 26 days ago

https://github.com/generalaction/emdash

A desktop application for running multiple AI coding agents in parallel, providing isolation via Git worktrees across local or remote (SSH) projects. Includes documentation for the Bring Your Own Infrastructure (BYOI) testing kit, SSH development containers, and @emdash/wire, a transport-agnostic runtime layer for typed API calls and real-time data synchronization.

Tokens
99.2K
Snippets
177
Records
678
Agent score
89%

What's inside Emdash

  1. Overview of @emdash/wire

    main

    @emdash/wire is a transport-agnostic runtime layer designed for typed API calls and real-time data synchronization. It provides a structured way to handle live model subscriptions, logs, event streams, jobs, and mutations at the API boundary.

    The package is organized into four functional layers:

    1. Runtime utilities: Manages lifecycle via Scope, ManagedSource, and ProcessHost supervision.
    2. API layer: Handles contract definitions via defineContract, controller management via createController, and various Transports (memory, port, electron, etc.).
    3. Live primitives: Manages stateful entities like LiveState, LiveLog, EventStreamSource, LiveJob, and Mutations.
    4. Observability: Provides cross-cutting instrumentation and logging hooks across all layers.
  2. Understand the Workspace Server connection topology

    main

    The connection between the Desktop client and the Workspace Server follows a specific topology designed for macOS and Linux remotes. Instead of connecting via a TCP port, the Desktop client connects via an SSH tunnel that forwards a Unix domain socket from the remote host.

    Key components:

    • Desktop Main Process: Uses an SshConnectionManager and SshClientProxy to manage the connection.
    • SSH Forwarding: The proxy uses openssh_forwardOutStreamLocal to bridge the connection to the remote socket.
    • Remote Host: The workspace server daemon listens on a Unix domain socket (typically ~/.emdash/workspace-server/run/workspace.sock).
    • ACP Runtime: The daemon forks an ACP (Agent Control Protocol) runtime child process in socket mode. Desktop clients access this via client.acp.* calls, which the daemon forwards to the child process.
  3. Understand the chat-ui caching strategy

    main

    Caching in chat-ui is layered and per-instance. All mutable data caches are owned by a ChatCaches bundle created in ChatRoot, ensuring that multiple mounted chats do not share state. Teardown is handled via caches.clear().

    Cache Layers

    | Layer | Key | Bound | Reach | | --- | nodeMemo | ChatItem identity | WeakMap (auto-GC) | Skips whole-row re-measure for committed rows | | blockMemo | Block identity | WeakMap (auto-GC) | Skips per-block re-measure inside streaming rows | | parseBlocks | messageId + text | per-instance Map | Identity-stable Block refs across re-renders | | prepareRichInline | shaped content | per-instance Map | Reuses pretext shaping; flushed on width/font change | | highlight | lang + code | per-instance LRU(200) | Shiki tokenisation | | computeDiff | oldText + newText | per-instance LRU(100) | Myers diff rows |

    Invalidation

    • Container-width or font-load change: Triggers caches.clearTextMeasure(), which drops the rich-inline cache and flushes pretext's internal global metrics.
    • Identity memos: Self-invalidate via fingerprint (theme.version | width | collapsed | expanded).
    • Shiki engine: Remains a global singleton (stateless/expensive); only token results are cached per-instance.
  4. Understand the ACP Runtime Child process

    main

    In socket mode, the Workspace Server daemon forks an ACP (Agent Control Plane) runtime as a child process using @emdash/wire/worker.

    • API Exposure: The ACP runtime is mounted under the workspaceWireContract.acp namespace.
    • Communication: The parent daemon communicates with the child via the acpApiContract using the process IPC channel.
    • Environment: Provider CLIs run within the child process. The environment passed to these CLIs is restricted to an allowlist via a shared spawn-context resolver; the full daemon environment is not forwarded.
    • Session Management: startSession and resumeSession calls return a { sessionId } through the ACP API. The connected desktop client is responsible for persisting these IDs.
  5. Understand Wire error planes

    main

    Wire distinguishes between two types of errors to help you decide how to handle them:

    1. Domain Failures (Result payloads): Use these for expected business logic failures. When defining procedure contracts, prefer using .fallible() so that callers receive failures as ordinary data rather than exceptions.
    2. Infrastructure Failures (WireError): These are reserved for system-level issues like transport failures, contract drift, lifecycle mistakes, or uncaught exceptions. WireError instances include a typed code, a message, and an optional serialized cause.
  6. Understand Mutations and Mutation IDs

    main

    Mutations connect API calls to live model updates. They allow updating a single model, multiple instances of a model, or several model references.

    Crucially, mutations use a mutationId to tag emitted LiveUpdates. This allows clients to bridge the gap between an RPC result (which only confirms the server handler finished) and the actual application of patches in the UI. A client can use waitForMutation(mutationId) to resolve once its local model has applied the update associated with that ID.

    server.produce(
      (draft) => {
        draft.tasks.push({ id: 'task-2', title: 'Apply the first patch', done: false });
      },
      { mutationIds: ['example-add-task'] }
    );
  7. Understand Plan Mode capabilities and restrictions

    main

    When Plan Mode is active, the agent operates under a strict read-only constraint.

    Capabilities:

    • Read files and examine code.
    • Search the codebase and analyze project structure.
    • Review documentation and external sources.
    • Propose strategies and implementation plans.

    Restrictions:

    • Cannot edit or apply changes to files.
    • Cannot run commands that modify the system (including installs or config changes).
    • Cannot create, delete, or rename files.
    • Cannot make git commits or push branches.
  8. Use the Ambient Logger for context-aware logging

    main

    Wire uses an ambient logger from @emdash/shared/logger to attach context (like requestId) to logs without passing a logger instance through every function.

    In Node.js environments, you must install the AsyncLocalStorage store at your entry point to ensure context is preserved across asynchronous boundaries.

    In Browser/Renderer environments, a synchronous fallback is used. While it supports scoped blocks, it will not preserve context across await calls.

    import { installAsyncLogContext } from '@emdash/shared/logger/context-node';
    
    // Call this at your Node.js entry point
    installAsyncLogContext();
    
    // Usage example for scoped logging
    import { log, runWithLogger } from '@emdash/shared/logger';
    
    await runWithLogger(logger.child({ requestId: 'r1' }), async () => {
      log.info('handling request');
    });
  9. Use eventStream for loss-tolerant notifications

    main

    The eventStream({ key, event }) function provides a keyed server-to-client event channel designed for loss-tolerant notifications. It is intended for scenarios where subscribers can recover from missed events by resyncing from a source of truth (like a database, cache, or file tree).

    When to use eventStream:

    • Use when subscribers can recover from missed events.
    • Do not use for retained append-only text (use liveLog instead).
    • Do not use for convergent state (use liveModel instead).

    Key Characteristics:

    • Events are at-most-once.
    • No historical events are retained for late subscribers.
    • Events are only delivered to currently attached clients.
  10. Implement a managed workspace connection on the Desktop

    main

    To maintain a stable connection across renderer reloads or transient feature detaches, the desktop should implement a managed source keyed by the SSH connectionId. This allows for a grace period (e.g., 30 seconds) to keep the connection warm.

    When implementing the connection logic, the flow involves:

    1. Connecting via sshConnections.connect(connectionId).
    2. Ensuring the workspace daemon is running.
    3. Using proxy.forwardOutStreamLocal(WORKSPACE_SOCKET_PATH) to obtain a channel.
    4. Adapting the channel with streamTransport.
    5. Initializing the workspace client with the appropriate protocolVersion.
    const workspaces = createManagedSource({
      key: (key: { connectionId: string }) => key.connectionId,
      graceMs: 30_000,
      async create({ connectionId }, scope) {
        const transport = reconnectingTransport(async () => {
          const proxy = await sshConnections.connect(connectionId);
          await ensureWorkspaceDaemon(proxy);
          const channel = await proxy.forwardOutStreamLocal(WORKSPACE_SOCKET_PATH);
          return streamTransport(channel, channel);
        });
        scope.add(() => transport.close());
    
        const connection = connect(transport);
        const workspace = client(workspaceWireContract, connection);
        const initialized = await workspace.initialize({ protocolVersion: PROTOCOL_VERSION });
        if (!initialized.success) {
          throw new WorkspaceProtocolError(initialized.error);
        }
        return { client: workspace, connection };
      },
    });
  11. Define inline mutation handlers for optimistic updates

    main

    To enable optimistic previews, you must define inline mutation handlers within your liveModel definition. Schema-only mutations (those without an inline handler) will skip the local preview and only update once the server responds.

    Use ctx.produce() within the handler to modify the state of different group members (e.g., state, usage) based on the mutation input.

    const api = defineContract({
      conversation: liveModel({
        key: conversationKeySchema,
        states: {
          state: liveState({ data: stateSchema }),
          usage: liveState({ data: usageSchema }),
        },
        mutations: {
          setTitle: mutation(
            { input: z.object({ title: z.string() }), data: stateSchema, error: z.string() },
            (ctx, input) => {
              ctx.produce('state', (draft) => {
                (draft as { title: string }).title = input.title;
              });
              ctx.produce('usage', (draft) => {
                (draft as { tokens: number }).tokens += input.title.length;
              });
              return ok({ title: input.title });
            }
          ),
        },
      }),
    });