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 thetmux/capturesnapshot events; - the client half — the
tmux-captureconversation 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 tobash/zsh/$SHELL/…), or doneRegexmatches the captured output, or- the timeout (
timeoutMin, defaultwatchTimeoutMin) 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:
- 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: falseopts into falling back to the most recent pane this plugin created — still never a foreign pane.)tmux_run/tmux_watchwithout a target create a fresh window (or a fresh session when none exists); they never touch an existing pane's content unnamed. - Destructive ops require an explicit flag AND approval.
tmux_killdemandsconfirm: true(schemaconst, re-checked in the body, and enforced by a monotonic guard that later listeners cannot undo). Ops listed inconfig.approvalare routed through the approval seam — withdsh-tool-approvalinstalled the op raises anapproval/request; absence fails closed. - Never send free-form text as tmux commands.
send-keysalways 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_watchto drive a persistent pane on a cron (nightly build in a pane, capture the tail). No coupling beyond the sharedctx.jobsseam. - dsh-crosstalk — set
crosstalkPeer: <session>and watch completionsend_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.approvalraise anapproval/requestin 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 mocktmuxonPATHthat records argv (lossless, base64 per arg) and returns canned-Foutput; unit tests run with no tmux installed.test/integration.test.ts— spawns a realtmux new -d -s dshteston a private socket, drives the send/capture round-trip, asserts list/layout output, runstmux_run/tmux_watchend-to-end, then tears down withtmux_kill(confirm: true).- The client bundle (
lib/client.js) is built byscripts/build-client.mjsand registers under the package id viawindow.__ModuleLoader__.load(...), so the harness serves it as/plugins/@dsh-tmuxctl/bundle/client.jswith zero build steps on install.
Known limitations (v0.1)
- Watch pollers are process-local like every
ctx.jobsproducer. The pane and its process survive harness restarts (tmux owns them) and the model can re-attach by%paneIdfromtmux_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 inconfig.shellsneeds adoneRegex(or a config entry). - Captures are bounded by
captureMaxBytes; a huge scrollback is truncated at the byte budget (markedtruncated). - 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
promo/slideshow.html— 60-second keyboard-advanceable pitch (1280×720), withpromo/narration.txt.
License
MIT
Links
More in this category
hust-open-atom-club/oh-dsh★ 161
Community distribution: TUI, desktop, and Web UI as one bundle with layered installation.
Jayden-X-L/forkprobe★ 65
Compare multiple skills on the same task and pick the winner.
vlln/plugin-registry★ 33
Ecosystem infrastructure: a thin browser console for managing official repository plugins (zero patches) plus a make-dsh-plugin skill for guided plugin development.
forrestchang/dsh-multica-runtime★ 28
Run the dsh runtime on Multica.
DietCokewithSugar/dsh-user-experience★ 18
Finds potential UX issues in your project: automatically reviews React/TypeScript code, pinpoints each problem, and gives concrete suggestions.
omdsh-dev/dsh-plugin-check★ 17
Plugin health checks: manifest protocol / patch format / build traps, zero-dependency and read-only.