DeepSeek Harness Plugin

Jesse-njx/dsh-tmuxctl

Stars ★ 0 Category Development & Runtime Added 2026-08-14

Take control of your tmux panes: list/send-keys/capture, run long jobs in a pane with watch mode, and approval-gated destructive commands.

Install

# from GitHub (first run asks for allowBuilds approval — follow the hint, retry)

dsh plugin --profile web add github:Jesse-njx/dsh-tmuxctl

GitHub-sourced plugins run build scripts on your machine at install time. Only install sources you trust, and pin a commit (github:owner/repo#sha).

README

The control plane for tmux — let the agent drive the terminals you already have open, not just name them.

dsh-tmuxctl is a plugin bundle for DeepSeek Harness. DSH core ships dsh-tmux-context, a passive plugin that names the pane the harness lives in; tmuxctl is the active half. It turns your existing tmux grid — servers, log tails, REPLs, build watchers — into an addressable surface the agent can list, type into, capture, rearrange, and watch, with safety by default. Because tmux owns every process, a watched build survives harness restarts: the pane is still there, and the agent re-attaches to it by %paneId.

Pure CLI wrapping over the tmux binary — no daemon, no long-lived process of our own.

  ┌──────────────┐   tmux_list / tmux_send_keys / tmux_capture / tmux_split / tmux_swap
  │  the agent   │ ───────────────────────────────────────────────────────────▶  your tmux
  │  (DSH)       │   tmux_run (foreground, stabilized)                              grid
  └──────┬───────┘   tmux_watch (background job, done via shell-return / doneRegex)
         │  durable context (next turn) + snapshot cards (web chat) + optional crosstalk

Install

# Install into the profile you chat/agent from (web, or your headless profile):
dsh plugin add github:Jesse-njx/dsh-tmuxctl

# or, once published to npm:
dsh plugin add @dsh-tmuxctl/bundle

That one command installs both halves:

  • the host half — eight tmux_* tools, the destructive-op approval gate, and the tmux/capture snapshot events;
  • the client half — the tmux-capture conversation node that renders each snapshot as a collapsed-by-default, expandable card in the web chat, so log tails never flood the transcript.

tmux must be installed and on PATH (brew install tmux, apt install tmux, …).

The tools

Tool What it does Canonical output
tmux_list Sessions, windows (with layout), panes (target, %paneId, size, current command, active, created-by-us) { sessions, windows, panes }
tmux_send_keys Type literal text into a pane (send-keys -l --), optionally Enter { target, sent, enter }
tmux_capture Capture a pane's visible text, optionally -S -<lines> scrollback { target, text, lineCount, truncated }
tmux_split Split a window beside a target, mark the new pane created-by-us { paneId, target }
tmux_swap Swap two panes structurally — no keystrokes { src, dst }
tmux_run Run a command in a fresh pane, wait until output stabilizes (two identical polls) or the timeout elapses { paneId, text, stabilized, elapsedMs }
tmux_watch Run a command in a fresh pane in the background; finish on shell-return, doneRegex match, or timeout; inject the result as durable context { kind: 'background', jobId }
tmux_kill Kill a server/session/window/pane — requires confirm: true and (per config) routes through approval { scope, target?, killed }

All outputs are structured JSON, so Code Mode gets a real API (await tools.tmux_list(...)); human prose stays in each tool's render.

Watch mode — "run this and tell me when it's done"

tmux_run blocks the current turn. tmux_watch does not: it registers a background job through ctx.jobs, polls capture-pane every panePollMs, and finishes when:

  • the pane's foreground command returns to the shell (#{pane_current_command} back to bash/zsh/$SHELL/…), or
  • doneRegex matches the captured output, or
  • the timeout (timeoutMin, default watchTimeoutMin) elapses — the pane is left alive.

On done, the tail is injected as durable context (agent.inject, form notice) the next model request sees — inject is not a wake-up — and a tmux/capture snapshot card is emitted. job_kill or job cancel stops the poller but never kills the pane: tmux owns it. If the pane vanishes mid-watch, the job resolves { status: 'failed', detail: 'pane-gone' }.

Snapshot cards

Every tmux_capture, tmux_run, and tmux_watch completion appends one durable tmux/capture event ({ paneId, target, lineCount, truncated, preview, fullText }). The client conversation node (@dsh-tmuxctl/bundle/client) turns each into a collapsed-by-default, expandable card: header + preview when collapsed, the full text in a bounded scroll region when expanded. Replay purity holds — the preview is the durable payload, never a re-capture, and the renderer reads node.data only.

Safety by default

Three hard rules, enforced in the tool layer and re-enforced through the pre-execute waterfall:

  1. Never touch a pane we did not create without an explicit named target. Every pane-acting tool requires a non-blank target validated against ^[%A-Za-z0-9_.:-]+$ — a missing or blank target is a validation error, never a guess. (requireExplicitTarget: false opts into falling back to the most recent pane this plugin created — still never a foreign pane.) tmux_run/tmux_watch without a target create a fresh window (or a fresh session when none exists); they never touch an existing pane's content unnamed.
  2. Destructive ops require an explicit flag AND approval. tmux_kill demands confirm: true (schema const, re-checked in the body, and enforced by a monotonic guard that later listeners cannot undo). Ops listed in config.approval are routed through the approval seam — with dsh-tool-approval installed the op raises an approval/request; absence fails closed.
  3. Never send free-form text as tmux commands. send-keys always uses -l -- <text>: model text can never be reinterpreted as a tmux key-name (C-c) or command. A control key is a separate explicit arg (enter: true), never smuggled through the text.

Config

plugins:
  dsh-tmuxctl:
    socket: default             # tmux -L socket name, or "default"
    requireExplicitTarget: true # never act on panes we didn't create without a named target
    approval: [kill-server, kill-session, kill-window, "send-keys:*"]  # ops routed to approval
    panePollMs: 2000            # run/watch poll interval
    watchTimeoutMin: 120        # default tmux_watch timeout
    runTimeoutMs: 120000        # default tmux_run timeout
    stabilizeMs: 2000           # default tmux_run quiet period
    captureMaxBytes: 100000     # capture byte budget (beyond it, truncated)
    shells: [bash, zsh, fish, sh, dash, ksh, tcsh, csh, nu, elvish, xonsh, pwsh, powershell]
    crosstalkPeer: ""           # optional: watch-completion tails also go to this dsh-crosstalk peer

approval entries are op globs: kill-server / kill-session / kill-window / kill-pane and send-keys:<target>. "send-keys:*" routes every keystroke send through approval for locked-down deployments (default: sends allowed, only kills gated).

Composing with the suite

  • dsh-routines / jobs — a scheduled routine can call tmux_run/tmux_watch to drive a persistent pane on a cron (nightly build in a pane, capture the tail). No coupling beyond the shared ctx.jobs seam.
  • dsh-crosstalk — set crosstalkPeer: <session> and watch completion send_messages the captured tail to that peer ("build in repo-A finished"). Detected at runtime; absent → local inject only.
  • dsh-tool-approval — install it and the kills in config.approval raise an approval/request in your UI; without it, those ops are denied (fails closed).

Development

pnpm install
pnpm typecheck
pnpm build      # host (tsc) + client bundle (esbuild) → lib/
pnpm test       # node --test; mock-tmux unit tests always run,
                # real-tmux integration tests feature-detect (skip if absent)
  • test/fixtures/mock-tmux.sh — a mock tmux on PATH that records argv (lossless, base64 per arg) and returns canned -F output; unit tests run with no tmux installed.
  • test/integration.test.ts — spawns a real tmux new -d -s dshtest on a private socket, drives the send/capture round-trip, asserts list/layout output, runs tmux_run/tmux_watch end-to-end, then tears down with tmux_kill (confirm: true).
  • The client bundle (lib/client.js) is built by scripts/build-client.mjs and registers under the package id via window.__ModuleLoader__.load(...), so the harness serves it as /plugins/@dsh-tmuxctl/bundle/client.js with zero build steps on install.

Known limitations (v0.1)

  • Watch pollers are process-local like every ctx.jobs producer. The pane and its process survive harness restarts (tmux owns them) and the model can re-attach by %paneId from tmux_list (createdByUs: true), but the poll loop itself does not survive a restart.
  • The shell-return detector knows a fixed shell-name set plus $SHELL; an exotic login shell not in config.shells needs a doneRegex (or a config entry).
  • Captures are bounded by captureMaxBytes; a huge scrollback is truncated at the byte budget (marked truncated).
  • Targets are restricted to ^[%A-Za-z0-9_.:-]+$ — session names with spaces or unusual characters are not addressable (a deliberate safety trade-off).
  • Remote tmux over SSH, plugin-manager integration, and a GUI pane-layout editor are out of scope for v0.1.

Non-goals (v0.1)

tmux server management UI; plugin-manager integration; remote tmux over SSH; GUI pane layout editor. No daemon and no persistent state store of our own — tmux is the source of truth for panes.

Promo

License

MIT

Content from the project README on GitHub ↗

Links

More in this category

View the whole category →