Skip to content

Writing an Adapter

An adapter is a single Go file that teaches gmux how to work with a specific tool. It lives in packages/adapter/adapters/ and is compiled into both gmux and gmuxd.

Read this page if you are adding support for a new tool. If you want the system-level overview first, see Adapter Architecture. This page stays focused on the implementation recipe.

Create packages/adapter/adapters/myapp.go:

package adapters
import (
"path/filepath"
"github.com/gmuxapp/gmux/packages/adapter"
)
func init() {
All = append(All, &MyApp{})
}
type MyApp struct{}
func (m *MyApp) Name() string { return "myapp" }
func (m *MyApp) Discover() bool { return true }
func (m *MyApp) Match(cmd []string) bool {
for _, arg := range cmd {
if filepath.Base(arg) == "myapp" {
return true
}
if arg == "--" { break }
}
return false
}
func (m *MyApp) Env(_ adapter.EnvContext) []string { return nil }

That’s enough for a valid adapter. It:

  • reports whether the tool is available on this machine with Discover()
  • activates when the command matches myapp
  • contributes no extra environment yet
  • is available for richer optional capabilities later

Sessions of a minimal adapter automatically get the runner’s default turn model: active (Active=true) from launch, upgraded to per-command turns if the output ever carries OSC 133 prompt marks, otherwise idle when the process exits — which is what makes gmux wait work on them out of the box.

Write tests in myapp_test.go alongside it.

If the adapter should appear in the UI launch menu, implement Launchable on the same struct:

type Launchable interface {
Launchers() []Launcher
}
func (m *MyApp) Launchers() []adapter.Launcher {
return []adapter.Launcher{{
ID: "myapp",
Label: "MyApp",
Command: []string{"myapp"},
Description: "My tool",
}}
}

gmuxd derives the launch menu from the compiled adapter set by checking which adapters implement Launchable. It then filters that menu using the adapter’s required Discover() method.

Adapters may expose zero, one, or many launch presets. The built-in shell fallback also implements Launchable, so shell appears in the menu without a separate special registry.

Every adapter implements four methods:

type Adapter interface {
Name() string
Discover() bool
Match(command []string) bool
Env(ctx EnvContext) []string
}

Name() returns a short identifier like "pi" or "myapp".

Discover() reports whether the backing tool is available on the current machine. gmuxd runs this in parallel for all compiled adapters during startup and only includes launchers from adapters whose discovery succeeds. Keep it cheap and deterministic. For example, shell returns true, while pi checks that the pi binary is on PATH (exec.LookPath) without executing it — running the binary would be too slow.

Match(cmd) receives the full command array and decides whether this adapter should handle it. Match on filepath.Base(arg) so full paths and wrappers work. Stop scanning at "--".

Env(ctx) returns extra environment variables for the child. The runner already sets GMUX, GMUX_SOCKET, GMUX_SESSION_ID, GMUX_ADAPTER. Most adapters return nil.

Note there is deliberately no per-byte PTY inference hook: adapters never parse output to guess state. Session status comes from exactly three sources — agent hooks (for SessionExtender/SessionHookCommand adapters), the runner’s default turn model (everything else: OSC 133 prompt marks when present, process lifetime otherwise), and the child’s explicit PUT /status escape hatch.

Important: adapters do not rewrite the user’s command

Section titled “Important: adapters do not rewrite the user’s command”

The command the user typed is what runs. Env() can add environment variables; adapters don’t inject flags or wrap the process. The only sanctioned argv change is the hook-injection seam (SessionExtender/SessionHookCommand, below), which the runner drives.

type Status struct {
Active bool // true while a turn is open (pulsing dot)
Error bool // true on a retryable or terminal error (red dot)
Interrupted bool // true when the last turn was stopped on purpose
}

Status carries only booleans; any display text is derived by the frontend from these plus the exit code. Status.Active is the session’s turn state: hooks flip it for agents, and the runner’s default turn model flips it for everything else.

When gmux launches a command, adapters are tried in this order:

  1. GMUX_ADAPTER env var — explicit override, validated against Match(). If the named adapter doesn’t match the command (or is unknown), resolution falls through — this prevents a GMUX_ADAPTER leaked from a parent session from forcing the wrong adapter.
  2. Registered adaptersMatch() in registration order; first match wins
  3. Shell fallback — always matches

A false negative is low cost because the shell adapter still handles the session.

The base interface covers command matching, env injection, and PTY monitoring. Additional opt-in interfaces add richer integration. Implement them on the same struct; gmux or gmuxd discover them via type assertion.

For the runtime behavior behind these interfaces, see Adapter Architecture.

type Launchable interface {
Launchers() []Launcher
}

Implement this if the adapter should contribute launch presets to the UI.

  • return one launcher for a simple tool entry
  • return multiple launchers if one adapter supports multiple presets
  • return none by not implementing the interface at all
  • remember that launch availability is controlled separately by the required Discover() method
type ConversationDescriber interface {
DescribeConversation(ref string) (*ConversationInfo, error)
}

Implement this if your tool persists conversations gmux should be able to inspect. Conversations are located by an opaque, adapter-scoped ref string: you pick the locator (the file-backed adapters use the transcript’s absolute path; a database-backed tool would use a row key or UUID), and gmux never interprets it — it only stores refs and hands them back to you.

DescribeConversation(ref) resolves a ref to display metadata: ID, title, slug, cwd, created time, LastActivity (freshness — file-backed adapters report the transcript’s mtime), and message count. Set Ref to the ref you were given so resume commands that embed a locator can use it.

type ConversationSource interface {
SnapshotConversations(sink ConversationSink)
WatchConversations(ctx context.Context, sink ConversationSink) error
}

Implement this if your tool’s conversations live in files (or anywhere else) that gmux should index for URL resolution and search. The adapter owns how it discovers them: SnapshotConversations reports everything that exists now (synchronous, at startup), and WatchConversations streams changes until ctx is cancelled. Both report opaque refs to a ConversationSink (Upsert(ref) / Remove(ref)); the daemon resolves each via your DescribeConversation.

File-backed adapters build both on packages/adapter/filewatch, a reusable recursive tree watcher, in a few lines each — see pi.go. A non-file source (e.g. a database) implements the same interface with a poller or subscription instead.

Note: live session state — title, status, and the held conversation — is not reported here. That flows authoritatively from the agent hook (SessionExtender for pi, SessionHookCommand for codex/claude); see below and Adapter Architecture.

These are the way an agent adapter reports authoritative session identity, titles, and turn status (ADR 0010/0011/0013; protocol in docs/runner-hook-protocol.md). The runner splices the hook into the launch argv:

  • SessionExtender.ExtendCommand — injects an extension file into the agent’s own extension mechanism (pi: -e <ext.mjs>).
  • SessionHookCommand.HookCommand — injects config overrides that make the agent run gmux __<agent>-hook on lifecycle events (claude: --settings, codex: -c hooks.…).

Only opt in if gmux fully controls the argv — a shell-wrapped launch (bash -c 'claude') can’t be extended and simply runs without live state. GMUX_NO_AGENT_HOOK disables injection.

Optional. Lets the startup retention reconcile distinguish a deleted conversation from unavailable storage, so dead sessions are only retired when their conversation is genuinely gone (ADR 0016). The file-backed agent adapters implement it via the shared ConversationGoneAtRoot helper.

type ConversationOpener interface {
OpenConversation(ref string) (io.ReadCloser, error)
}

Optional. Streams a conversation’s raw, adapter-native content (e.g. the JSONL transcript) for derived consumers such as the fulltext search index. The file-backed adapters implement it as os.Open(ref).

Optional lifecycle callbacks: OnRegister runs at registration time (return a slug, write a state file — shell and editor use this), OnDismiss cleans up when a session is dismissed.

Optional. Marks one-shot invocations (e.g. pi update, pi --version) that should be exec’d directly instead of wrapped in a session. Implement this for CLI tools with management subcommands.

There is no capability for status. Sessions of adapters that implement SessionExtender or SessionHookCommand (adapter.HookDriven) get their turn state exclusively from the agent hook. Every other adapter’s sessions run the runner’s default turn model: Active=true from launch; OSC 133 prompt marks (“semantic prompt” sequences) in the output — 133;C busy, 133;A/133;D idle — upgrade the session to per-command turns; without marks, the process exit closes the single lifetime turn (idle + unread, error on non-zero exit). The upgrade is evidence-based rather than declared, so bash -c script one-shots and mark-emitting ssh sessions both classify correctly, whatever adapter matched them.

type Resumer interface {
// An empty command means the described conversation is not resumable.
ResumeCommand(info *ConversationInfo) []string
}

Implement this if your tool supports resuming previous sessions.

  • ResumeCommand(info) tells gmux how to resume the described conversation, and returns nil for an invalid, empty or incompatible one. There is no separate resumability probe: the empty command is the “not resumable” verdict, and gmux treats nil and a zero-length slice identically.

When a session dies with a recorded conversation ref (ConversationRef, reported by the agent hook), your adapter is the authority for that session’s resume: gmux derives the command from ResumeCommand and shows the session as resumable only if the derivation is non-empty. Return an empty command and the session is presented as not resumable rather than falling back to its original launch command — the daemon resolves the same way when it actually spawns, so anything else would advertise a resume it would then refuse.

A conversation that your adapter declares non-resumable is also left out of the conversation index (no URL slug, no search entry).

Sessions that never recorded a conversation ref are outside this path; do not rely on Resumer to describe their behavior. Only implement Resumer when your tool has native resume support you want gmux to use.

type AgentActionEncoder interface {
EncodeAction(action AgentAction) (input string, ok bool)
ActionReadyTimeout() time.Duration
}

Optional. Encodes gmux’s semantic turn-control actions as terminal input for your agent:

  • ActionSend — submit the composed prompt now (steers the current turn when the agent is busy);
  • ActionSendAfterTurn — queue the composed prompt until the current turn ends;
  • ActionInterrupt — abort the current turn.

It is a pure, stateless encoding: prompt text is not part of an action (it is delivered as ordinary input first), and the adapter holds no session state. ActionReadyTimeout only states the deadline policy for your agent’s startup — observing readiness and enforcing the deadline are gmux’s job, not yours.

pi implements it (Enter, Kitty CSI-u Alt+Enter, Kitty CSI-u Escape, 10s). There is deliberately no default — an adapter without this capability has no semantic action support, and callers must fail loudly rather than guess a keystroke. Raw gmux send works for every adapter regardless. Return ok=false for an action your tool can’t express.

Semantic actions travel gmux’s semantic path, which stays permanently separate from raw input, so your encodings are never checked against the raw gmux send --wait submit guard — there is nothing to keep in sync.

Encoding an action is only half of the capability: the runner will not deliver a single byte of a semantic action until your agent’s hook reports {"op":"ready"} (see the runner hook protocol). Report readiness as soon as the agent’s composer accepts input, independently of any conversation bind, or the first gmux agent prompt of a fresh session waits out ActionReadyTimeout and fails. A readiness timeout delivers nothing at all, so it stays safe to retry.

Your hook’s turn start event matters for the same reason: after delivering a prompt the runner refuses further prompts that need an idle agent until it sees a fresh active turn, so an agent that reports only turn ends accepts one semantic prompt per session and refuses the rest.

Two caveats worth copying if your agent is keystroke-driven like pi’s. pi’s interrupt is not a clean stop: the same handler restores queued follow-ups into the composer, so text the user never retyped can be left there. And the non-Enter keybinds are user-configurable, so a user who remaps alt+enter or escape silently loses semantic follow-up / interrupt support — the bytes still arrive, they just no longer mean that action.

type CommandTitler interface {
CommandTitle(command []string) string
}

Implement this to customize the fallback title derived from the command array. Without it, the store joins the full command (e.g. pytest -x). Adapters that use resume commands implement this to avoid titles like codex resume 019cfb54-…; the editor adapter shows the edited file’s basename.

This only matters when no adapter or shell title has been set yet, which is rare for agent adapters (titles come from the agent hook or stored conversation) but common for plain shell sessions.

An adapter implements only what it needs:

AdapterBaseLaunchableConversationDescriberConversationSourceResumerOther
ShellCommandTitler, SessionRegistrar, SessionFinalizer
EditorCommandTitler, SessionRegistrar
ClaudeConversationOpener, ConversationProber, SessionHookCommand
CodexConversationOpener, ConversationProber, SessionHookCommand
PiConversationOpener, ConversationProber, SessionExtender, PassthroughDetector, AgentActionEncoder

Shell writes a small JSON state file per session under ~/.local/state/gmux/shell-sessions/ and resumes by launching $SHELL in the original cwd. The editor adapter (backing gmux edit) is a good minimal non-agent example: it matches an internal sentinel command and is ephemeral (auto-dismissed on close).

A house pattern worth copying: assert your capabilities at compile time — var _ adapter.Resumer = (*MyApp)(nil).

Write unit tests in myapp_test.go next to your adapter. Test Match() with different command shapes, and any capability methods with representative inputs.

If the adapter implements Launchable, test the returned launcher IDs, labels, and commands.

For adapters with ConversationDescriber, create temp conversations in your tool’s format and verify DescribeConversation() extracts the expected metadata.

For the full end-to-end pipeline (launch → file attribution → title → resume), add integration tests that run real processes through gmuxd. See Integration Tests for the harness, patterns, and gotchas.