The profile path has two independent process trees

User terminal
     │ keystrokes / frames
     │ fd 3 / fd 4
     ▼
┌──────────────────────── Martty Client process ────────────────────────┐
│ Cordis Client tree → UI services → semantic snapshots → Rust painter │
│             stdin/stdout are reserved for Host ACP                   │
└───────────────────────────────▲───────────────────────────────────────┘
                                │ initialize, session/*, _dsh/cordis/*
                                │ ACP over Client stdin / stdout
┌───────────────────────────────▼───────────────────────────────────────┐
│ DSH Base tree → ACP server plugin → model, tools, Host plugins       │
└────────────────────────── DSH Host process ───────────────────────────┘
The crossed channels are deliberate. Host ACP never travels on the inherited TTY descriptors, and terminal bytes never enter the Host's ACP parser.

Source map

These repository paths are pinned to the verified revision shown above, so each claim and excerpt can be checked against the implementation that the article describes.

PathResponsibility
docs/architecture.en.mdNormative Host/Client process contract
npm/lib/client-process.jsIndependent Client entry point and fd mapping
npm/lib/spawn-tui.jsNative painter spawn, fd and Windows TCP transports
npm/lib/acp-client.jsSpawn-or-stream ACP client service
src/main.rsNative attach modes and ACP endpoint selection
src/acp.rsOfficial ACP client and session projection

Start from process ownership, not from the screen

Running dsh --profile martty means DSH remains the composition root. Its Base Cordis tree mounts the agent-facing ACP plugin and Host-side packages. The Martty runner then starts an independent Node Client process. That process builds the terminal-side Cordis tree and launches the native painter. There is no shared dependency-injection container hiding behind the pipe.

This distinction prevents a subtle deployment bug. If the terminal package imported DSH and spawned it again, the user could end up with two different Host trees, two plugin registries, and two notions of the active session. A screen could look healthy while commands or authentication were applied to the wrong process. The profile contract gives every durable fact one owner: DSH owns agent execution; the Client owns presentation state.

Why ACP and the TTY use crossed channels

The Client process receives Host ACP on its standard input and sends ACP responses on standard output. At the same time, it inherits the user's terminal on descriptors 3 and 4. client-process.js intentionally reverses the stream names when it calls bootClient: from the Client's perspective, process.stdin contains bytes written by the Host, and process.stdout returns bytes to the Host; the real terminal is a separate object.

The native Rust painter is then spawned with its own standard input and output attached to that TTY. On Unix, Node and Rust exchange their private compositor protocol over two extra pipes. On Windows, or when DSH_TUI_FORCE_TCP=1, the same semantic channel uses token-authenticated loopback TCP. Neither implementation gives a theme or slot plugin a terminal handle; only the trusted painter controls raw mode and escape sequences.

Excerptnpm/lib/client-process.js
await bootClient({
  // ACP is connected to the Host runner through this process's stdio.
  stream: { stdin: process.stdout, stdout: process.stdin },
  extraArgs: process.argv.slice(2),

  // The user terminal is inherited separately. These descriptors never
  // carry Host-to-Client ACP messages.
  tty: { stdin: 3, stdout: 4 },
  packagePlugins: parseClientPluginsEnv(
    process.env.DSH_TUI_CLIENT_PLUGINS_V0,
  ),
})

stream is the ACP connection already owned by the Host runner; the Client does not spawn another Harness on this path.

tty is capability-minimized input/output for the trusted terminal shell. Plugin code receives semantic services instead of these descriptors.

Initialization is the compatibility gate

The first successful paint proves only that the native binary and terminal setup work. The first protocol proof is ACP initialize. Martty sends its implementation identity and declares filesystem, terminal, session-configuration, authentication, and elicitation capabilities. The agent response determines whether session loading, image prompts, auth methods, and optional DSH features are actually available.

DSH-specific methods are gated by _meta.dsh.cordis.protocol = 0. The Rust client records that bit in its Surface; calls such as plugin listing or UI selection first pass ensure_agent_cordis. A plain ACP agent can therefore run sessions without pretending to understand DSH extensions. An unsupported extension is a disabled feature, not a reason to corrupt the base client connection.

The transcript comes from session/update, never from Host internals

After initialization and authentication, Martty creates or loads an ACP session. Prompts, cancellation, permission requests, configuration changes, and agent updates all cross the standard protocol. In src/acp.rs, each typed SessionNotification is serialized into an internal AppEvent::Rpc with method session/update. The application layer folds that event into transcript nodes, command catalogs, mode choices, usage, and plan state.

That extra projection layer is essential for a long-running agent. A tool card can begin as pending and later be replaced in place; a partial assistant message can grow without duplicating earlier text; resize can reflow the same state. If the Host wrote terminal lines directly, scrollback and update ordering would become transport artifacts. ACP events describe state changes; the painter decides how that state occupies cells.

Host plugins and Client plugins are different products

A Host plugin changes agent-side behavior and must project anything user-visible through ACP. A Client plugin changes presentation or interaction on the terminal side. Martty does not synchronize plugin ids, Cordis inject declarations, or fibers between the two processes. Doing so would turn a serialized protocol boundary back into a distributed in-memory framework with undefined lifecycle rules.

Themes and shell slots are registered as sibling Client plugins. They contribute palettes or validated TuiNode trees through services such as tuiTheme and tuiSlots; Node serializes snapshots to the Rust painter. Conversation content is intentionally not an open slot. The agent remains the source of durable transcript updates, so a decorative plugin cannot impersonate a user, tool, or assistant event.

Shutdown is part of the architecture

A full-screen terminal changes raw mode, alternate-screen state, mouse capture, and cursor visibility. Leaving an orphan painter behind is not a cosmetic failure: it can keep the TTY unusable after the Host exits. client-process.js converts SIGTERM, SIGINT, and SIGHUP into orderly process.exit calls so the Client's exit handlers tear down the native child and restore the terminal.

The reverse direction also matters. The shell remembers when the user intentionally quits and refuses to respawn an empty TUI on the same terminal. Spawned ACP agents are terminated when their specification changes or the client exits. These ownership rules make Ctrl+C, profile reload, and failed native startup observable rather than producing invisible background processes.

Debug from the outer process inward

An empty timeline is not one failure category. It can mean the profile did not load, the Client failed before spawning the painter, ACP initialization never completed, authentication blocked session creation, or updates reached the client but failed to project. Debugging from pixels backward mixes all five layers and usually leads to changing CSS or model configuration without evidence.

Use the ownership chain as the diagnostic order. First confirm the profile package graph. Then prove both processes exist and the painter can attach. Next prove ACP initialization and inspect the advertised auth methods. Only after session/new succeeds should you investigate model routes, permissions, or a missing session/update. This order narrows the fault without assuming that a visible frame means a usable agent.

Standalone Martty changes the connection owner, not the protocol

The martty executable can also be the client composition root. In that mode it may spawn dsh-acp, run dsh --profile acp, launch another ACP-compatible command, or attach to caller-provided streams. The executable and argument list are explicit data; repeated --agent-arg flags avoid asking a shell to reinterpret a single command string.

Use standalone mode for protocol testing, non-DSH agents, or a parent application that already owns the server pipes. Use the profile when DSH should own Host packages and upgrades. Both routes converge on the same ACP session model, so switching launch topology should not require a second transcript implementation.

npm install --global marttymartty --check-runtimemartty --agent <acp-command> --agent-arg <argument>

Failure modes worth distinguishing

Start with the first check in the same row. Each symptom can look similar on screen, but it belongs to a different ownership or state boundary.

SymptomLikely causeFirst check
The terminal opens, but no agent name appearsThe painter is alive but ACP initialize is pending or failedRun martty --check-runtime and inspect the agent stderr
The profile starts two agent runtimesThe Client was configured to spawn instead of receiving the Host streamInspect the runner config and confirm config.stream reaches the Client
Theme or slot actions work, but prompts do notThe private compositor channel is healthy while Host ACP is brokenTrace initialize and session/new on Client stdin/stdout
Prompts work, but DSH plugin controls are absentThe agent did not negotiate Cordis protocol 0Inspect agentCapabilities._meta.dsh.cordis.protocol
The shell is corrupted after Ctrl+CThe painter outlived the Client or skipped terminal restorationVerify signal handlers and native child exit cleanup

Reproduce the profile and protocol boundary

dsh plugin --profile martty listdsh --profile marttymartty --check-runtime

Expected evidence

  1. The profile list contains martty, and starting the profile creates one DSH Host plus one TUI Client—not a second Harness tree.
  2. The runtime check prints initialize ok ... → <agent-name>; a live prompt produces session/update traffic before transcript paint changes.
  3. On Unix, the Host ACP path is Client stdin/stdout while the TTY is fd 3/4. Closing either process restores the terminal and terminates its owned child.

Common implementation questions

Is Martty a fork of DeepSeek Harness?

No. DSH remains the Host runtime. Martty is a separate ACP client composition and native terminal renderer.

Does `dsh --profile martty` start another Harness inside the TUI?

No. The Host runner passes its ACP stream to the Client. Spawn mode is for standalone connections, not the primary profile topology.

Why not pass the TTY through ACP?

ACP carries semantic session operations. Raw terminal bytes have different security, lifecycle, and backpressure rules, so Martty keeps them on a private trusted channel.

Can Martty still connect to a non-DSH agent?

Yes. Standard ACP is the base contract. DSH Cordis features appear only after protocol-0 capability negotiation.