Skip to content

Design typed operations and engines #688

Description

@tony

Summary

This issue proposes a public operation and engine architecture for libtmux.
The goal is to keep the existing object API compatible while moving command construction, scope validation, execution strategy, and typed result handling into explicit reusable layers.

The core shape:

  • libtmux.ops owns inert typed operation values, operation scopes, target refs, result types, and serialization.
  • libtmux.engines owns execution strategies, including subprocess, control mode, async subprocess, async control mode, lazy planning, and deterministic concrete/mock execution.
  • Existing Server, Session, Window, Pane, and Client objects remain the compatibility facade. Their methods build operations, run them through a bound engine, and adapt typed results back to today's return values.

This is an architecture target, not a requirement to rewrite every method at once. New operation-backed features can follow this design, and existing methods can migrate incrementally.

Context

libtmux has historically exposed tmux through object methods on Server, Session, Window, Pane, and Client. Those objects are the right user-facing shape: they mirror tmux's server, session, window, pane, and client model, and they carry parent links that make traversal natural.

The implementation currently mixes several concerns inside those object methods:

  • building tmux command names and arguments;
  • deciding which target scope a command belongs to;
  • applying tmux-version-specific flags;
  • dispatching through a subprocess;
  • parsing stdout into Python objects;
  • returning compatibility values expected by existing callers.

The experimental chain work separated some of those concerns. It introduced immutable command calls, command metadata, chainability checks, forward refs, sync and async plan runners, and a control-mode runner. That work showed the direction but is still scoped to libtmux._experimental.chain.

The next architecture should make operation authoring independent from the execution engine. A typed operation should be inert: it can be serialized, validated, run immediately, deferred into a lazy plan, executed by an async runner, or filled with concrete/mock results in tests. The public object API must remain compatible while becoming a facade over this operation substrate.

Scopes and object links

Every operation belongs to exactly one tmux scope.

Scope Object facade Parent link Typical commands
server Server none new-session, list-sessions, set-option -g
session Session server new-window, rename-session, kill-session
window Window session split-window, select-layout, rename-window
pane Pane window send-keys, capture-pane, resize-pane
client Client server view detach-client, switch-client, client formats

Client is a view into a live attachment, not part of the ownership chain. It still has operation scope because tmux exposes client-scoped commands and formats.

Operations store refs, not live objects. A ref may be concrete (%3, @2, $1, a client name), symbolic ({marked}, last-pane, active), or deferred (slot 0 from a previous operation). Parent links are represented as refs so operations can be serialized and executed outside the original Python object graph.

Operation values

An operation is an immutable typed value. It carries enough information to render a tmux command, validate it for a target scope, and describe the result type, but it does not dispatch.

Minimum operation fields:

  • kind: stable operation discriminator, such as split_window.
  • scope: one of server, session, window, pane, or client.
  • target: typed target ref or None.
  • command: tmux command name.
  • args: typed argument values before rendering.
  • tmux_version: optional version constraints or variants.
  • result_type: the specialized result model this operation returns.
  • effects: metadata such as creates pane, writes pane input, reads output, mutates layout, or destroys object.

Operation classes may expose friendly constructors and Pythonic field names, but the serialized form uses stable public names. The operation registry is the source of truth for command name, scope, version variants, result type, chainability, and safety metadata.

Version-specific behavior belongs on the operation class or registry entry. For example, an operation can declare that a flag is available only on tmux 3.4+, or that a fallback rendering is required below that version. Engines provide the tmux version; operations decide whether they can render against it.

Result values

All engines return the same typed result shape for the same operation. The base result vocabulary is:

  • operation: the operation that was executed.
  • argv: rendered tmux argv.
  • status: complete, failed, skipped, or unknown.
  • returncode: tmux return code when known.
  • stdout: captured stdout lines.
  • stderr: captured stderr lines.
  • payload: the operation-specific typed value.

Specialized results sit on top of this base. Examples:

  • SplitWindowResult contains the new pane ref when captured.
  • CapturePaneResult contains captured pane lines.
  • SendKeysResult contains no payload beyond base command status.
  • SelectLayoutResult contains the target window ref and status.

Results do not raise on construction. Raising is an opt-in helper, matching CPython's CompletedProcess.check_returncode() pattern. This lets batch and lazy callers inspect partial success, skipped operations, and unknown timeout states without losing the result list.

Engine families

An engine executes operations. Engine interfaces should be protocols so tests and downstream packages can provide concrete engines without inheriting from a base class.

Proposed engine modules:

  • libtmux.engines.classic: synchronous subprocess execution. This backs the existing Server behavior first.
  • libtmux.engines.control: synchronous persistent tmux -C execution for batched commands with per-command %begin, %end, and %error results.
  • libtmux.engines.asyncio: asynchronous subprocess execution and the shared async engine protocol. The package should not use libtmux.engines.async because async is a Python keyword.
  • libtmux.engines.async_control: asynchronous persistent tmux -C execution. This should own async process I/O, cancellation, command correlation, and future control-mode event streaming rather than being only a to_thread wrapper around the sync control engine.
  • libtmux.engines.lazy: synchronous lazy planning. It records operations and compiles them later through another engine.
  • libtmux.engines.async_lazy: asynchronous lazy planning over the async engine protocol.
  • libtmux.engines.concrete: deterministic in-memory execution for tests, docs examples, and downstream mock integrations.

The object shapes are:

  • Server: classic sync facade, defaulting to the subprocess engine.
  • LazyServer: sync facade that records operations.
  • AsyncServer: async facade that awaits engine execution.
  • AsyncLazyServer: async facade that records operations and awaits execution when resolved.

Control mode should be an engine choice rather than a separate object hierarchy. For example, a sync Server can be bound to ControlModeEngine, and an AsyncServer can be bound to AsyncControlModeEngine.

The same shape applies below the server: Session, Window, Pane, and Client instances are bound to the engine or lazy plan owned by their server.

Execution modes

Operation authoring and operation execution are separate.

  • urgent: Build an operation from a live object, execute it immediately, and adapt the typed result to the existing public method return.
  • deferred: Build an operation graph with refs to values that do not exist yet. Execution resolves refs and returns typed results in operation order.
  • lazy: Record operations without touching tmux. The caller can inspect, serialize, optimize, or later execute the plan.
  • concrete: Fill operations with deterministic data from an in-memory engine. This is for tests, docs, and downstream tools that need libtmux-shaped behavior without a tmux server.

These modes must not change the operation's result type. A SplitWindow operation returns a SplitWindowResult whether it ran urgently, came from a lazy plan, or was filled by a concrete engine.

Compatibility

Existing public methods stay available. Compatibility wrappers may continue to return Pane, Window, Session, list[str], None, or tmux_cmd where they do today. Internally, those methods should converge on:

  1. Build operation.
  2. Execute through the bound engine.
  3. Convert the typed result into the legacy return value.

New APIs should prefer returning typed operation results directly. Existing APIs can add opt-in result access only where it does not break established return types.

The current libtmux._experimental.chain API should be treated as a prototype and source of implementation experience, not as the final public module layout. Stable pieces can move into libtmux.ops and libtmux.engines after their contracts are tested across engines.

Serialization

Operations and results must serialize without live Python objects. Serialized payloads include stable kind names, scope names, target refs, args, version constraints, status, stdout/stderr, and typed payload fields. They do not include Server, Session, Window, Pane, Client, subprocess handles, control-mode process handles, or event-loop objects.

The core should use stdlib dataclasses and typed protocols first. Optional adapters may expose Pydantic models, JSON Schema, or MCP schemas at package edges, but the core operation system should not require Pydantic to import or run.

Testing

The operation contract should be tested once and reused across engines.

Contract tests should cover:

  • operation rendering by scope and tmux version;
  • serialization round trips;
  • result equality across urgent, lazy, async, async control-mode, and concrete execution;
  • classic subprocess and control-mode parity where both transports support the command;
  • deferred refs across session, window, and pane scopes;
  • error, skipped, and unknown timeout states;
  • compatibility wrapper return values for existing public methods.

Use parametrized pytest cases with stable IDs for the shared contract matrix. Async variants should use pytest-asyncio fixtures or markers rather than blocking the event loop with sync subprocess work.

Consequences

Positive outcomes:

  • Command construction becomes testable without tmux.
  • Sync, async, lazy, and concrete execution share one typed operation surface.
  • MCP and other downstream tools can consume serializable operations and typed results instead of raw command strings.
  • Existing object methods remain compatible while gaining a clearer internal boundary.
  • Control mode can improve batching and per-command results without changing operation authoring.
  • Async control mode can support cancellation and future event streaming without forcing sync control-mode internals through thread wrappers.

Tradeoffs:

  • libtmux must maintain operation and result classes in addition to the classic object facade.
  • Version-specific command rendering becomes explicit metadata, so the registry needs review discipline.
  • Compatibility wrappers may temporarily duplicate logic while methods migrate.
  • Lazy and concrete engines can expose stale-state assumptions that urgent live methods currently hide.
  • Control mode introduces process lifecycle and event-correlation responsibilities that subprocess-per-command execution avoids.

Main risk: engine sprawl. The mitigation is a small engine protocol and a shared contract test suite. New engines must prove they return the same typed results for the operations they claim to support.

Serialization drift is another risk. The mitigation is a versioned serialized schema with round-trip tests and explicit compatibility policy before exposing operation payloads as stable public data.

Deferred decisions

This proposal does not decide:

  • the complete operation registry;
  • the exact class names for every operation and result;
  • whether Pydantic adapters ship in core, an extra, or downstream packages;
  • the public stability date for libtmux.ops and libtmux.engines;
  • rollback/compensation semantics for multi-operation plans;
  • batching and optimization rules beyond engine-owned execution;
  • control-mode subscription/event streaming APIs.

Prior art

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions