File: README.md
Emdash is a desktop app for running AI coding agents in parallel. Each task runs in its
own Git worktree, so you can explore multiple fixes or features at once, review the
diffs, and merge what works.
It works with local projects and remote machines over SSH. Bring the CLI agents you
already use: Claude Code, Codex, OpenCode, Amp, and more.
What You Can Do
- Run multiple coding agents at once without juggling terminals.
- Keep every agent isolated in its own Git worktree and branch.
- Send issues and tickets from Linear, GitHub, Jira, GitLab, Asana, Featurebase,
Monday.com, Forgejo, Plain, or YouTrack into an agent. - Review diffs, create pull requests, inspect CI checks, and merge from one place.
- Work locally or on your own remote machines over SSH/SFTP.
Installation
| Platform | Install |
|---|---|
| macOS | brew install --cask emdash ยท Apple Silicon ยท Intel |
| Windows | Installer ยท Portable |
| Linux x64 | AppImage ยท DEB ยท RPM |
| Linux ARM64 | AppImage ยท DEB ยท RPM |
See the latest release for
all desktop builds.
Agents
Emdash detects installed provider CLIs automatically. It supports agents like Claude
Code, Codex, Cursor, OpenCode, Amp, Devin, Qwen Code, Droid, and GitHub
Copilot.
For agents with lifecycle-hook support, Emdash installs marker-tagged entries in the agent's
user-level config. These hooks let Emdash track status, notifications, and resumable sessions, and
silently do nothing when the agent runs outside an Emdash session.
See Providers for the full list, setup commands,
and provider-specific behavior.
Remote Projects
Connect to remote machines with SSH/SFTP and run the same parallel workflow on remote
codebases. Emdash supports SSH agent, key, and password authentication, including OpenSSH
certificates held by the agent, with credentials stored in your OS keychain.
See Remote Projects for setup details.
Privacy
Emdash is local-first. App state is stored in a local SQLite database, and Emdash does
not send your code or chats to Emdash servers.
Agent CLIs may send code, prompts, and context to their own providers. Their data
handling depends on the provider you choose.
Telemetry is optional and can be disabled in Settings or by launching with:
TELEMETRY_ENABLED=falseSee Telemetry for details.
Contributing
Contributions are welcome. Read the Contributing Guide, open an
issue, or join the Discord.
License
Licensed under the Apache-2.0 license.
File: agents/README.md
Agent Docs
This directory is the system of record for agent-facing repo guidance. Keep topic pages small, specific, and mechanically checkable where possible.
Recommended Reading Order
quickstart.mdarchitecture/overview.md- the task-specific page for the area you are changing
Directory Layout
architecture/- system structure and major code ownership boundaries
core-modules.mdโ Core runtime, service, and primitive module types, platform surfaces, dependency rules, and subpath exportsworkspace-server.mdโ protocol versioning, negotiation handshake, and behavior-change rules for the remote workspace daemonstate-ownership.mdโ desktop-owned entities, host-owned resources, bindings, and the placement rule for operation behavioracp-runtime.mdโ ACP runtime, session manager, connection pool, cells, live models, and API contract ownershipgit-runtime.mdโ Git contract/runtime ownership, session lifecycle, and nested wire compositionpath-system.mdโ host-aware path identity, resource URI ownership, and future migration boundaries
workflows/- task-oriented procedures like testing, worktrees, remote development, and Nx task orchestration
integrations/- provider, MCP, and external service guidance
integration-plugins.mdโ integration auth and credential contracts, stable account identity, and issue-plugin boundaries
risky-areas/- places where incorrect changes are expensive
conventions/- coding contracts and repo rules
ui-kit.mdโ which UI kit to use:@emdash/uifor components, feature Tailwind for layout one-offs, where new primitives go,cn()location, themingui-styling.mdโ vanilla-extract styling rules forpackages/ui
Maintenance Rules
- Prefer one page per concrete topic.
- Avoid volatile counts unless you can verify them cheaply.
- Link to the source-of-truth file paths.
- Update the smallest relevant page instead of expanding
AGENTS.md.
File: apps/workspace-server/docs/client-connection.md
Workspace Server Client Connection
This document describes the target desktop-to-workspace-server connection model.
Only macOS and Linux remotes are in scope for this design.
Topology
flowchart LR
subgraph desktop [Desktop Main Process]
source["ResourceCache per target"]
reconnecting[reconnectingTransport]
local[Local Unix socket connector]
sshManager[SshConnectionManager]
proxy[SshClientProxy]
end
subgraph host [Workspace Host]
socket["Unix socket ~/.emdash/workspace-server/run/workspace.sock"]
hub[createWireSessionHub]
controller[workspaceWireContract controller]
runtimes["required core runtime subprocesses"]
end
source --> reconnecting
reconnecting --> local
local --> socket
reconnecting --> sshManager
sshManager --> proxy
proxy -->|"openssh_forwardOutStreamLocal"| socket
socket --> hub
hub --> controller
controller --> runtimesThe workspace server daemon listens on a Unix domain socket. The socket lives in
a user-owned directory with mode 0700; SSH authentication plus filesystem
permissions form the access boundary. Desktop clients do not connect to a TCP
port on the remote host.
serve --socket is the daemon serving mode. serve --stdio serves the same
wire controller over stdin/stdout and exists as a test and debugging harness.
The daemon runs every core runtime in a required supervised subprocess in both socket and stdio
modes. Clients reach ACP, agent config, automations, conversations, file search, files, Git,
resource usage, terminals, TUI agents, and the workspace registry through the corresponding
aggregate client domains. Live state, jobs, event streams,
mutations, terminal logs, and file blob channels all use the same reconnectable workspace wire
connection.
Desktop Utility Shape
The desktop should expose a resource cache keyed by the full workspace-server
target. Local sockets are the first implementation; SSH becomes another stream
source without changing handshake or reconnect behavior. Including the socket path
in the key avoids conflating custom daemon sockets on the same host.
type WorkspaceServerTarget =
| { kind: 'local-socket'; socketPath: string }
| { kind: 'ssh'; sshConnectionId: string; socketPath: string };
const workspaces = createResourceCache({
key: workspaceServerTargetKey,
idleTtlMs: 30_000,
async create(target: WorkspaceServerTarget, scope) {
let negotiated: WireInitializeResult | undefined;
const transport = reconnectingTransport(
() =>
openInitializedWorkspaceTransport(target, (value) => {
negotiated = value;
}),
{
shouldRetry: (error) => !(error instanceof WorkspaceProtocolError),
}
);
scope.add(() => transport.close());
const connection = connect(transport);
scope.add(() => connection.dispose());
const workspace = client(workspaceWireContract, connection);
await transport.ready();
return { client: workspace, connection, negotiated: () => negotiated };
},
});
async function openInitializedWorkspaceTransport(
target: WorkspaceServerTarget,
onInitialized: (value: WireInitializeResult) => void
): Promise<WireTransport> {
const stream = await openWorkspaceServerStream(target);
const candidate = ownedStreamTransport(stream);
const handshakeConnection = connect(candidate);
const handshakeClient = client(workspaceWireContract, handshakeConnection);
try {
const initialized = await handshakeClient.initialize({
protocolVersion: PROTOCOL_VERSION,
client: { id: clientId, appVersion },
});
if (!initialized.success) throw new WorkspaceProtocolError(initialized.error);
onInitialized(initialized.data);
return candidate;
} catch (error) {
candidate.close?.();
throw error;
} finally {
handshakeConnection.dispose();
}
}openWorkspaceServerStream() initially opens a manually-started local daemon's
Unix socket. The later SSH implementation obtains the stable proxy fromSshConnectionManager, ensures the daemon exists, and opens a streamlocal channel
to the same socket path.
ownedStreamTransport() wraps streamTransport(stream, stream) and destroys the
underlying socket/channel from close(). streamTransport() itself only releases
its parsing listeners because it does not own the streams passed to it.
ensureWorkspaceDaemon() is a future desktop bootstrap step. It should call the
server-side lifecycle CLI to probe the socket, start the daemon if absent, and
leave version upgrades to the wire update flow. After the daemon exists, eachconnectOnce uses the current ssh2 client exposed by SshClientProxy to open anopenssh_forwardOutStreamLocal channel and adapts it withstreamTransport(channel, channel). A convenience method may be added to the proxy
when the SSH connector is implemented.
Handshake and Readiness Invariants
Each connectOnce attempt owns one physical stream and performs the initialize
handshake before returning it to reconnectingTransport. Therefore:
initializeis the first workspace-server procedure on every candidate stream.- A compatible response records the negotiated feature level and daemon identity.
- Only then can the stable transport flush queued messages and announce reconnect.
connect()reattaches live topics after that reconnect notification.- An incompatible response closes the candidate, rejects
ready(), and stops retries.
The temporary handshake Connection is disposed without closing a successful
candidate. The stable outer Connection is the only connection that remains
subscribed after readiness.
Failure Semantics
- SSH connection drops: the streamlocal channel closes,
streamTransportemits
disconnect, andreconnectingTransportretriesconnectOnce. - SSH reconnects:
SshConnectionManager.connect(connectionId)reuses or waits
for the managed SSH connection using the existing credential resolution. - Workspace daemon restarts:
connectOncereopens the Unix socket, initializes the
new daemon generation, and exposes its newdaemonIdonly after negotiation. - Live attachments:
connect()observesonReconnectfromreconnectingTransportafter initialization, re-attaches topics, and replicas
resync from fresh snapshots. - Pending calls: in-flight calls reject with
DISCONNECTED; callers decide
whether procedure retries are safe. Live model mutations can use mutation IDs
and retry options. - Calls made while disconnected:
reconnectingTransportqueues non-blob messages
in a bounded drop-oldest buffer. Features should wait forready()and use
cancellation/deadlines so an evicted request cannot wait indefinitely. - Protocol mismatch:
initializereturns a typedResulterror withaction: 'upgrade-client' | 'upgrade-server'.shouldRetryclassifies it as permanent;
the owner surfaces the error and invalidates the cache entry. The future update
flow should routeupgrade-serverinto workspace-server self-update.
Server Modes
Socket mode:
emdash-workspace-server serve --socket
emdash-workspace-server serve --socket ~/.emdash/workspace-server/run/workspace.sockThe server ensures the socket directory exists, probes before unlinking stale
socket files, and serves each accepted net.Socket throughcreateWireSessionHub.
Daemon lifecycle:
emdash-workspace-server start
emdash-workspace-server status
emdash-workspace-server stopLifecycle commands use socket mode by default. They derive sidecar files from
the socket path so custom paths remain self-contained:
~/.emdash/workspace-server/run/workspace.sock
~/.emdash/workspace-server/run/workspace.sock.pid
~/.emdash/workspace-server/run/workspace.sock.lock
~/.emdash/workspace-server/run/workspace.sock.log
~/.emdash/workspace-server/state/start probes the socket first, acquires the lock, spawns serve --socket as a
detached process when needed, and waits for health to succeed. stop reads the
pid file and sends SIGTERM, relying on the daemon signal handlers to dispose
the socket server and unlink the socket file. status connects to the socket and
calls health.
ACP Session IDs
ACP start and resume return { sessionId } directly to the desktop client
through the workspace wire connection. The desktop side should persist the
returned session id when remote ACP session resume is wired.
Stdio mode:
emdash-workspace-server serve --stdioStdio mode must not write logs to stdout because stdout is the wire protocol
channel. It is intended for local integration tests and manual debugging, not as
the production SSH transport.
Related Docs
apps/workspace-server/docs/daemon.mdpackages/wire/docs/api/serving.mdpackages/wire/docs/api/transports.mdpackages/wire/docs/runtime/lifecycle.mdpackages/wire/docs/runtime/workers.md
File: apps/workspace-server/docs/daemon.md
Workspace Server Daemon
The workspace server is a per-user Node process that exposes theworkspaceWireContract over either a Unix domain socket or stdio. The production
remote path is socket mode: the desktop connects over SSH stream-local forwarding
to the daemon's Unix socket, and the daemon serves each accepted socket as an
independent wire session.
Stdio mode runs the same controller over stdin/stdout. It is primarily a test and
debugging harness because stdout is reserved for the wire protocol.
Process Roles
The command line has two roles:
serveis the foreground server process. In socket mode, this is the daemon.start,stop, andstatusare short-lived lifecycle commands that manage or
inspect the daemon process.
flowchart LR
cliStart["CLI: start"] -->|"probe health"| socket["workspace.sock"]
cliStart -->|"spawn detached serve"| daemon["CLI: serve --socket"]
daemon -->|"listen"| socket
daemon -->|"write pid"| pidFile["workspace.sock.pid"]
cliStatus["CLI: status"] -->|"wire health()"| socket
cliStop["CLI: stop"] -->|"read pid, SIGTERM"| daemon
desktop["Desktop over SSH"] -->|"streamlocal channel"| socket
socket --> sessionHub["WireSessionHub"]
sessionHub --> controller["workspaceWireContract controller"]
daemon --> workerHost["WireWorkerHost"]
workerHost --> runtimes["required core runtime subprocesses"]
workerHost --> watcher["FS watch subprocess"]
watcher --> runtimes
controller --> runtimes
runtimes -->|"spawn provider CLIs and PTYs"| agentCli["host-local processes"]Commands
Foreground serving:
emdash-workspace-server serve --socket
emdash-workspace-server serve --socket ~/.emdash/workspace-server/run/workspace.sock
emdash-workspace-server serve --stdioDaemon lifecycle:
emdash-workspace-server start
emdash-workspace-server status
emdash-workspace-server stopFor backwards compatibility, invoking the binary with only flags still meansserve:
emdash-workspace-server --socket
emdash-workspace-server --stdioLifecycle commands use socket mode. Passing --socket selects a custom
daemon instance; otherwise they use the default socket path.
Runtime Files
All runtime files are derived from the socket path so custom socket paths remain
self-contained:
~/.emdash/workspace-server/run/workspace.sock
~/.emdash/workspace-server/run/workspace.sock.pid
~/.emdash/workspace-server/run/workspace.sock.lock
~/.emdash/workspace-server/run/workspace.sock.log
~/.emdash/workspace-server/state/acp-attachments/
~/.emdash/workspace-server/state/acp-session-intents.json
~/.emdash/workspace-server/state/tui-agent-session-intents.json
~/.emdash/workspace-server/state/automations.db
~/.emdash/workspace-server/state/conversations.db
~/.emdash/workspace-server/state/file-search.db
~/.emdash/workspace-server/state/workspace-registry.db
~/.emdash/workspace-server/state/host-dependencies.json.sockis the Unix domain socket accepted by the foreground daemon..pidrecords the daemon process id after socket serving starts..lockprevents competingstartcalls from spawning duplicate daemons..logreceives stdout/stderr from the detached daemon process.state/stores runtime-owned databases, intents, attachments, and dependency selections. ACP
attachments are laid out per conversation.
The socket directory is created with mode 0700, so the filesystem boundary is
the local user account. Desktop clients reach the socket through SSH, not through
a TCP listener.
Start Flow
start is intentionally conservative:
- Derive socket, pid, lock, and log paths.
- Acquire the exclusive lock file.
- Probe the socket with the wire
healthprocedure. - If the daemon is already healthy, return successfully without spawning.
- Remove stale pid/socket files when no healthy daemon is present.
- Spawn
serve --socketas a detached child with stdout/stderr redirected
to the log file. - Wait until the socket answers
health, then exit.
The lifecycle command does not keep the daemon alive after spawning it. Once the
detached serve process is healthy, it owns the socket and pid file until it
exits.
Serve Flow
serve --socket owns the long-lived server lifecycle:
- Create the runtime state directories and the in-process host-dependency store.
- Start every required runtime and FS-watch subprocess and wait for readiness.
- Build the aggregate controller from the required runtime clients.
- Create the socket directory.
- Probe any existing socket file before unlinking it. A live socket blocks
startup; a dead socket file is removed. - Listen on the Unix socket and write the pid file.
- For every accepted
net.Socket, convert it to aWireTransportand open a
session increateWireSessionHub. - On
SIGINTorSIGTERM, dispose all wire sessions, close the server, unlink
the socket, remove the pid file, stop the runtime children, and exit.
The session hub is what lets one daemon serve multiple client connections while
keeping per-client live subscriptions, in-flight calls, and blob channels scoped
to the connection that created them.
Runtime Workers
Both serve modes mount every core runtime as a child process. The parent creates workers throughWireWorkerHost using executable paths declared in the workspace-server worker manifest. Each
child entry initializes structured process logging and calls runWireComponentWorker(...).
The worker dependency graph mirrors the desktop host:
- Conversations, FS watch, terminals, and resource usage have no runtime-worker dependencies.
- ACP and TUI agents consume conversations and the parent-owned host-dependency resolver; agent
config consumes the resolver only. - Files and file search consume FS watch; Git consumes FS watch and host dependencies.
- The workspace registry consumes FS watch, ACP, terminals, and TUI agents.
- Automations starts last and consumes the workspace registry, ACP sessions, TUI sessions, and
the conversation index.
Every runtime is required. Failure to start any worker aborts server startup, while failures after
readiness use the worker host's supervision policy. Runtime logs are emitted as structured stderr
lines and forwarded by the parent process.
ACP start and resume return { sessionId } through the ACP API. The
connected desktop client owns persistence for those returned ids when remote ACP
consumption is added.
Stop Flow
stop uses the pid file rather than a wire shutdown RPC:
- Read
<socketPath>.pid. - If the pid file is missing or the process is already gone, remove the stale pid
file and reportnot-running. - Send
SIGTERMto the recorded pid. - Poll until the process is gone and the socket no longer answers
health. - Remove the pid file.
There is no automatic SIGKILL escalation. A stuck process should be surfaced to
the caller rather than hidden.
Status Flow
status connects to the Unix socket and calls health. A healthy response prints
the socket path, version, and uptime. A failed probe exits non-zero and reports
whether the daemon is not running or unhealthy.
Code Map
src/index.tsparses config, dispatches commands, and installs signal handlers
forserve.src/config.tsdefines command and serve-mode parsing.src/daemon/paths.tscentralizes socket, pid, lock, and log paths.src/daemon/probe.tsimplements the wirehealthprobe.src/daemon/start.tsimplements locked detached startup and health waiting.src/daemon/stop.tsimplements pid-file basedSIGTERMshutdown.src/daemon/status.tsimplements the status probe.src/wire/serve-socket.tsowns Unix socket serving and maps each accepted
socket into a wire session.src/wire/serve-stdio.tsserves the same controller over stdin/stdout.src/api/controller.tsbuilds theworkspaceWireContractcontroller.src/gateway/workspace-workers.tsowns the complete required worker graph and runtime state
configuration.src/gateway/worker-manifest.tsselects the shared Core entries and app-local plugin entries for
each packaged worker executable.src/gateway/worker-paths.tsresolves packaged worker executable paths at runtime.src/gateway/entries/contains the ACP, agent-config, and TUI-agent entries that inject the
application plugin registry; all other workers reuse Core runtime entries directly.src/runtime/paths.tsowns runtime database, intent, and attachment paths.
Desktop Integration
Desktop-side daemon bootstrap is intentionally separate from this server package.
The intended flow is:
- Resolve or establish the SSH connection.
- Run
emdash-workspace-server starton the remote host. - Open an SSH stream-local channel to the socket path.
- Wrap that channel in
streamTransport(channel, channel). - Create a wire client and call
initialize.
See client-connection.md for the target desktop-to-daemon connection model.
See packaging.md for self-contained release artifact targets, layout, build commands, and Linux
compatibility constraints.
File: packages/wire/examples/README.md
@emdash/wire Examples
These examples show the public surface of @emdash/wire. Every example imports
the package entrypoints only (/rpc, /live, /state, via their in-repo
source paths) and splits the authoritative server side from the client-side
binding so the boundary looks like a real transport without adding an adapter.
Documentation
For conceptual docs that explain how the examples fit together, see
../docs/README.md.
Run them from the repository root:
pnpm --filter @emdash/wire run example:state-kernel
pnpm --filter @emdash/wire run example:live-log
pnpm --filter @emdash/wire run example:event-stream
pnpm --filter @emdash/wire run example:mailbox
pnpm --filter @emdash/wire run example:cancellation
pnpm --filter @emdash/wire run example:api-definition
pnpm --filter @emdash/wire run example:dedupe
pnpm --filter @emdash/wire run example:job-contractExamples:
state-kernel/demonstrates the state kernel end to end:cell+familyexposeon the server,remote()+observe+optimisticon the
client, including an optimistic mutation settling against the authoritative
cursor.
live-log/demonstrates a contract-levelliveLog()endpoint served by aLiveLogSource, consumed throughcreateLiveLogReplicaCacheandReplicaLog.event-stream/demonstrates a contract-leveleventStream()endpoint served
bycreateEventStreamHostwith client-side subscribe and gap handling.mailbox/demonstrates bounded local producer/consumer handoff, graceful close,
and explicit overflow.cancellation/demonstrates procedure cancellation withAbortSignaland
server-side abort on disconnect.api-definition/isolates contract definition withdefineContract,procedure,liveModel,liveLog, and live model contract
mutations.dedupe/demonstrates server-sidededuplicate()middleware from@emdash/shared/requestsfor in-flight procedure calls.job-contract/demonstrates the contract-levelliveJob()endpoint with start,
progress, cancellation, terminal result, and reattach.scope/andresource-cache/demonstrate the@emdash/shared/concurrency
primitives the live layer builds on.
File: packages/wire/docs/live/event-stream.md
Event Streams
eventStream({ key, event }) exposes a keyed server-to-client event channel for
loss-tolerant notifications. It reuses the wire live attachment protocol but does
not retain events or expose meaningful snapshots.
Use event streams when subscribers can recover from missed events by resyncing
from another source of truth, such as a file tree, cache, or database query. UseliveLog for retained append-only text and liveModel for convergent state.
Contract
const api = defineContract({
fileEvents: eventStream({
key: z.object({ rootPath: z.string() }),
event: z.object({
kind: z.enum(['create', 'update', 'delete']),
path: z.string(),
}),
}),
});The key addresses an event stream instance. The event schema types the
client-side onEvent callback and documents the payload shape.
Server
Most servers use createEventStreamHost():
const fileEvents = createEventStreamHost(api.fileEvents);
const controller = createController(api, {
fileEvents,
});
fileEvents.emit(
{ rootPath: '/repo' },
{ kind: 'update', path: '/repo/package.json' }
);emit() is fire-and-forget. If no client is attached to that key, the event is
dropped. When the last subscriber detaches, the host discards the keyed source.
If attachment presence owns an external resource, pass lifecycle hooks to the
host. onActive runs when the first subscriber attaches to a key, and onIdle
runs when the last subscriber detaches:
const resourceEvents = resourcedStream({
key: z.object({ rootPath: z.string() }),
event: z.object({ kind: z.string(), path: z.string() }),
});
const fileEvents = createEventStreamHost(resourceEvents, {
onActive(key) {
void startWatcher(key);
},
onIdle(key) {
void stopWatcher(key);
},
});This is useful for resources that should be recreated after reconnect. The wire
client automatically reattaches live topics after reconnect, so onActive runs
again in the new server process.
When a successful attach must mean that the resource is ready, define the endpoint withresourcedStream() and provide activate. Activation receives a signal and resolves to its owned
disposer:
const fileEvents = createEventStreamHost(api.fileEvents, {
async activate(key, signal) {
const watcher = await startWatcher(key, { signal });
return () => watcher.dispose();
},
});Concurrent subscribers share one activation. The host aborts pending activation when the final
subscriber leaves, disposes a late result, and keeps activation alive when another subscriber still
owns the key.
You can also pass a resolver to createController() if another component owns
the source:
createController(api, {
fileEvents: (key) => sources.get(key.rootPath),
});Client
const unsubscribe = await client.fileEvents.subscribe(
{ rootPath: '/repo' },
{
onEvent(event) {
console.log(event.kind, event.path);
},
onGap() {
void reloadFileTree();
},
onError(error, { retrying }) {
if (!retrying) showFileEventStreamError(error.message);
},
signal,
}
);onGap runs after the underlying transport reattaches. Events may have been lost
while the link was disconnected or while a supervised subprocess restarted, so
consumers should refresh any derived state in that callback.
Initial attachment does not call onGap; subscribers should perform their own
initial load before or after subscribing.
The subscribe() promise rejects if the initial attachment fails. After an
attachment is established, DISCONNECTED reattach failures call onError withretrying: true and remain registered for a later reconnect. Non-retryable
reattach errors call onError with retrying: false and terminate the
attachment.
Semantics
- Events are at-most-once and only delivered to currently attached clients.
- No historical events are retained for late subscribers.
- Event stream keys are validated by controller validation.
- Event payloads are not validated on the push path, matching other live updates.
- Forwarding preserves upstream gaps and reattach errors even when the downstream
transport remains connected.
Example
See ../../examples/event-stream/client.ts
for a runnable in-process example.
File: packages/wire/docs/live/live-job.md
Live Jobs
Live jobs model async work as a live state machine. They are useful when callers
need both a final result and a stream of progress updates.
State Shape
liveJobStateSchema(progress, result, error) creates a schema for:
type LiveJobState =
| { status: 'running'; startedAt: number; progress: P[]; progressCount: number }
| { status: 'succeeded'; startedAt: number; finishedAt: number; progress: P[]; result: R }
| { status: 'failed'; startedAt: number; finishedAt: number; progress: P[]; error?: E; cause?: SerializedError }
| { status: 'cancelled'; startedAt: number; finishedAt: number; progress: P[] };progress is a retained ring of recent entries. progressCount is the total
number of progress events emitted, including entries that may have rolled out of
the retained ring. Terminal states retain lifecycle timestamps and the most
recent progress entries, but they do not carry progressCount.
Server
LiveJobSource accepts a handler that returns Result<R, E>:
const jobs = new LiveJobSource(
async (input, ctx) => {
ctx.progress({ step: 'checkout' });
await build(input, ctx.signal);
ctx.progress({ step: 'package' });
return ok({ artifact: `${input.target}.zip` });
},
{ toError: (error) => ({ message: error instanceof Error ? error.message : String(error) }) }
);
const { jobId } = jobs.start({ target: 'desktop' });Options:
generation: optional fixed generation for every job state model.terminalRetainMs: how long terminal state remains attachable. Default:LIVE_JOB_TERMINAL_RETAIN_MS(5 minutes).idFactory: custom job id factory, useful for deterministic tests.clock: custom timestamp source, useful for deterministic tests.toError: optional fallback for unexpected thrown errors. Domain failures
should be returned aserr(error)from the handler.onRunStarted,onRunChanged, andonRunEvicted: optional hooks for callers
that want to maintain their own job listing.
Handlers receive LiveJobContext:
ctx.progress(value)appends retained progress and emits an update.ctx.signalis anAbortSignal. If the signal is aborted, the job finishes
ascancelled.
Return ok(result) for successful completion and err(error) for expected
domain failures. If the handler throws and toError is configured, the thrown
value is mapped into typed error; otherwise the failed state carries a
serialized cause.
source(jobId) returns a read-only live source for that job. snapshot(jobId) andgetState(jobId) are convenience accessors. cancel(id) aborts a running job.dispose() is async: it aborts running jobs, waits for cooperative handlers to
settle, clears eviction timers, and removes all job state.
Terminal job state is retained for LIVE_JOB_TERMINAL_RETAIN_MS (5 minutes) so
late clients can reattach by id. This retention is process-local, not durable.
Consumers
Consumers normally reach jobs through the API layer. The contract client starts,
cancels, and attaches to jobs without retaining local state:
const { jobId } = await contractClient.build.start({ target: 'desktop' });
const handle = contractClient.build.handle(jobId);
const detach = await handle.attach((update) => {
console.log(update.delta);
});
await contractClient.build.cancel(jobId);
detach();Use createLiveJobReplicaCache() when a consumer wants a result promise, progress
callbacks, ref-counted attachment, and terminal-state retention. If the job fails,ReplicaJob.result rejects with LiveJobFailedError; if it is cancelled, it
rejects with LiveJobCancelledError. Progress emitted by a seed is suppressed so
reattaching clients do not replay old progress as fresh events.
Contract Endpoint
The API contract layer can expose a job directly:
const api = defineContract({
build: liveJob({
input: z.object({ target: z.string() }),
progress: z.object({ step: z.string() }),
result: z.object({ artifact: z.string() }),
error: z.object({ message: z.string() }),
}),
});On the server, implement the endpoint with { run, toError? }:
const controller = createController(api, {
build: {
run: async (input, ctx) => {
ctx.progress({ step: 'compile' });
return ok({ artifact: `${input.target}.zip` });
},
toError: (error) => ({
message: error instanceof Error ? error.message : String(error),
}),
},
});The typed client exposes a live job client handle. Use it directly for
low-level forwarding, or wrap it in createLiveJobReplicaCache() when a consumer wants
ref-counted local state:
const jobs = createLiveJobReplicaCache(api.build, contractClient.build, { lingerMs: 30_000 });
const lease = await jobs.start({ target: 'desktop' });
const handle = await lease.ready();
handle.onProgress((progress) => console.log(progress.step));
console.log(await handle.result);
await lease.release();
const reattachedLease = jobs.acquire(handle.jobId);
const reattached = await reattachedLease.ready();
console.log(await reattached.result);
await reattachedLease.release();
await jobs.dispose();ReplicaJob exposes jobId, ready, result, getState(),onProgress(cb), and cancel(). The LiveJobReplicaCache manager owns ref counting,
subscription sharing, and terminal-state retention.
LiveJobSource accepts a Clock for deterministic start/finish timestamps and
terminal retention eviction. Server disposal is awaitable: it cancels active job
runs, waits for cooperative handlers to settle, and disposes retention timers
before returning.
Cancellation at the procedure/wire level is documented in
serving. Job cancellation is domain-level:ReplicaJob.cancel() calls the generated .cancel procedure, which callsLiveJobSource.cancel(jobId).
See ../../examples/job-contract/client.ts.
File: packages/wire/docs/live/live-log.md
Live Logs
Live logs are append-only text streams with retained tail snapshots. They are a
good fit for terminal output, build logs, and agent process output where clients
need to attach late and still see recent context.
Server
LiveLogSource stores a bounded text buffer. append(chunk) emits{ chunk } deltas to subscribers and updates the retained snapshot:
const server = new LiveLogSource({ generation: 3000, maxBufferBytes: 12 });
export function appendLine(line: string): void {
server.append(`${line}\n`);
}Options:
generation: optional fixed generation, useful in tests.maxBufferBytes: retained buffer size. The default is 1 MiB.
Snapshots contain LiveLogSnapshotData:
type LiveLogSnapshotData = {
baseOffset: number;
text: string;
truncated: boolean;
};baseOffset is the byte offset of text in the logical full log. When the
server drops old bytes because maxBufferBytes was exceeded, truncated istrue. The server keeps at least the newest chunk even if it exceeds the byte
limit.
reseed() starts a new generation and resets sequence to 0. With no argument,
it clears retained text and resets baseOffset to 0; with retained snapshot
data, it seeds the buffer from that data.
Consumers
Consumers normally reach logs through the API layer. The client handle is useful
when you want to stream directly into a terminal or log viewer:
const output = contractClient.activity.handle(session);
const snapshot = await output.snapshot();
term.write(snapshot.data.text);
const detach = await output.attach((update) => {
term.write((update.delta as { chunk: string }).chunk);
});Use createLiveLogReplicaCache() when a process wants a retained local buffer that can
also be served downstream. Without a custom sink, the replica keeps an eager
bounded LiveLogSource buffer and text() remains readable. With a custom LogSink,
the sink owns storage; readable LogStores add text(), while write-only sinks
can stream directly into xterm without a duplicate text buffer.
The replica's internal log follower tracks the followed generation/sequence
plus the UTF-8 byte offset already written to its sink. On reconnect or explicit refresh(), it
splices from the retained snapshot when the generation still matches, appending
only missing bytes instead of resetting rendered output. Generation changes or
evicted gaps still reset the sink from the retained snapshot.
The underlying protocol follower resyncs on update-before-seed, generation
mismatch, sequence gap, or invalid log delta. Resync events use the same resync
instrumentation hook as live models.
API Layer Usage
Contracts expose logs with liveLog({ key }):
const api = defineContract({
activity: liveLog({ key: sessionKeySchema }),
});
const controller = createController(api, {
activity: () => activityLogServer,
});
const activity = contractClient.activity.handle(session);
const detach = await activity.attach((update) => {
console.log((update.delta as { chunk: string }).chunk);
});
detach();The API layer handles topic encoding, snapshots, attachment, and detachment.
See ../../examples/live-log/client.ts.
File: packages/wire/docs/live/live-model.md
Live States and Protocol
Live states are reactive JSON state containers. A server owns authoritative
state, emits ordered Immer patches, and clients apply those patches locally.
When a client detects a gap, it refetches a snapshot and resumes from the new
generation.
Protocol Terms
The shared protocol lives in src/live/protocol.
LiveSnapshot<T>is the full state at a cursor:{ generation, sequence, timestamp, data }.LiveUpdateis an ordered delta:{ generation, baseSequence, sequence, timestamp, delta, mutationIds? }.LiveCursoris{ generation, sequence }and identifies a point in a live
state stream.LiveSourceis the common server-side shape consumed by the API layer:snapshot()plussubscribe(cb).
generation changes when the server calls reseed(). sequence increments for
each effective patch in a generation. baseSequence must equal the client's
current sequence; otherwise the client has missed an update and resyncs fromsnapshot().
Server
LiveStateSource<T> owns one authoritative state object. Mutate it withproduce(), not by mutating the original object:
const server = new LiveStateSource<TaskListState>(
{
tasks: [{ id: 'task-1', title: 'Read the plan', done: false }],
filter: 'all',
},
1000
);
const cursor = server.produce(
(draft) => {
draft.tasks.push({ id: 'task-2', title: 'Apply the first patch', done: false });
},
{ mutationIds: ['example-add-task'] }
);produce() returns the cursor containing the change. If the mutator is a no-op,
no update is emitted and the current cursor is returned. snapshot() deep
clones the current state. reseed(next?) replaces the generation, resets
sequence to 0, optionally replaces state, and forces clients to resync on the
next observed update.
Consumers
Consumers normally reach live states through a live model contract. Use the
contract client when you only need protocol access:
const model = contractClient.conversation.state({ sessionId }, 'state');
const snapshot = await model.snapshot();
const detach = await model.attach((update) => {
console.log(update.delta);
});Wrap that live state client handle in createLiveModelReplicaCache() when a process wants local
state, mutation settling, ref counting, or a downstream LiveSource; see
replicas.
The package also has low-level protocol followers internally. They consume
snapshots and ordered updates, then resync when:
- an update arrives before
seed(). generationdiffers from the local generation.baseSequencediffers from the local sequence.- Immer patch application throws.
- schema validation fails in non-production builds.
The reported resync reasons are generation, sequence-gap, patch-failed,
and validation. Schema validation is skipped when NODE_ENV === 'production';
the generation and sequence checks are the primary correctness mechanism.
Every follower is constructed with a required resync failure policy that
decides what happens when the snapshot refetch itself fails. Wire ships two
canned policies: resyncRetry({ schedule? }) retries on a bounded backoff
until success or dispose, and resyncMarkStale() gives up while keeping
staleness observable (stale on the client) until the next update or reattach
triggers a fresh attempt. Production replicas use resyncRetry. Every failure
fires the resyncFailed instrumentation event. refresh() resolves only when
the follower is fresh: concurrent calls coalesce onto the in-flight resync and
a trigger arriving mid-reseed re-runs the loop before resolution. Disposing a
follower-based client rejects all of its pending waiters โ including
wait-forever waiters (timeoutMs <= 0) โ with a disposed error.
Cursor and Mutation Waiters
waitForCursor(cursor, timeoutMs?) resolves when the client has reached a
cursor. waitForMutation(mutationId, timeoutMs?) resolves when an applied update
contains the mutation id, or when a seed/resync lands because the snapshot is
authoritative.
These waiters are used by mutation settling; see mutations.
See ../../examples/state-kernel/client.ts
for an end-to-end example.