Architecture map
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 ───────────────────────────┘Read the implementation
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.
| Path | Responsibility |
|---|---|
docs/architecture.en.md | Normative Host/Client process contract |
npm/lib/client-process.js | Independent Client entry point and fd mapping |
npm/lib/spawn-tui.js | Native painter spawn, fd and Windows TCP transports |
npm/lib/acp-client.js | Spawn-or-stream ACP client service |
src/main.rs | Native attach modes and ACP endpoint selection |
src/acp.rs | Official 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.
npm/lib/client-process.jsawait 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>Diagnosis
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.
| Symptom | Likely cause | First check |
|---|---|---|
| The terminal opens, but no agent name appears | The painter is alive but ACP initialize is pending or failed | Run martty --check-runtime and inspect the agent stderr |
| The profile starts two agent runtimes | The Client was configured to spawn instead of receiving the Host stream | Inspect the runner config and confirm config.stream reaches the Client |
| Theme or slot actions work, but prompts do not | The private compositor channel is healthy while Host ACP is broken | Trace initialize and session/new on Client stdin/stdout |
| Prompts work, but DSH plugin controls are absent | The agent did not negotiate Cordis protocol 0 | Inspect agentCapabilities._meta.dsh.cordis.protocol |
| The shell is corrupted after Ctrl+C | The painter outlived the Client or skipped terminal restoration | Verify signal handlers and native child exit cleanup |
Reproduce it
Reproduce the profile and protocol boundary
dsh plugin --profile martty listdsh --profile marttymartty --check-runtimeExpected evidence
- The profile list contains
martty, and starting the profile creates one DSH Host plus one TUI Client—not a second Harness tree. - The runtime check prints
initialize ok ... → <agent-name>; a live prompt producessession/updatetraffic before transcript paint changes. - 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.
Questions
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.