- Shell 96.6%
- Awk 2.3%
- Makefile 1.1%
| .forgejo/workflows | ||
| bin | ||
| doc/caveats | ||
| lib/dyad | ||
| libexec | ||
| test | ||
| vendor | ||
| .gitignore | ||
| .program.env.sh | ||
| CHANGELOG.md | ||
| LICENSE.md | ||
| Makefile | ||
| README.md | ||
Dyad
Dyad is a miniature process supervision primitive that runs two commands side-by-side with an optional pipe between them.
Jump to: Installation | Usage | Implementation Notes | Contributing | License
Overview
Each Dyad invocation runs a left side and a right side with shared fate:
dyad run [<OPTIONS>] [--] <LEFT> [...] :: <RIGHT> [...]
Dyad starts both commands and watches them together. When one side exits, Dyad signals and reaps the other. When the supervisor itself receives a signal, it forwards the signal to both sides and waits for them to exit.
You can customize Dyad’s behavior by specifying what happens when one side exits (--stop), which side’s exit status Dyad reports (--report), or whether the left side’s output is connected to the right side’s input (--channel, --pipe). The right side can itself be another dyad run, so two sides extend to a chain of three or more.
Dyad can also act as a portable replacement for Bash’s set -o pipefail: the dyad pipefail command runs LEFT | RIGHT and exits with a nonzero status if either side fails.
See Usage for details.
Portability
Dyad is written in POSIX shell, targeting the POSIX.1-2024 standard. It is architecture-independent and does not require a C compiler.
We test Dyad using Brat with continuous integration on the following platforms:
| sh | |
|---|---|
| Alpine Linux | busybox ash |
| Debian Linux | dash |
| Fedora Linux | Bash |
| FreeBSD | FreeBSD ash |
| macOS | Bash (3.2) |
Installation
Dyad runs entirely from source and has no build step or dependencies to install.
Installing Dyad Globally
Download and extract the latest release archive and symlink bin/dyad into your PATH. For example, to install Dyad in /usr/local:
# curl -sL https://codeberg.org/sstephenson/dyad/archive/latest.tar.gz | tar -C /usr/local -xzf -
# ln -s /usr/local/dyad/bin/dyad /usr/local/bin/dyad
Or if you prefer a per-user installation (assuming $HOME/.local/bin is in your PATH):
$ curl -sL https://codeberg.org/sstephenson/dyad/archive/latest.tar.gz | tar -C ~/.local -xzf -
$ ln -s ~/.local/dyad/bin/dyad ~/.local/bin/dyad
Vendoring Dyad in Your Project
Download the single-file bundle from the package registry and commit it directly to your repository:
$ mkdir -p vendor
$ curl -sL https://codeberg.org/api/packages/sstephenson/generic/dyad/latest/dyad >vendor/dyad
$ chmod +x vendor/dyad
$ vendor/dyad --help run
The bundle is a self-contained, plain-text POSIX shell script generated by brut-pack.
Usage
In its simplest form, dyad run takes a left command and a right command as argument lists on either side of a :: separator:
$ dyad run make assets :: make docs
dyad run starts both commands at once. By default, if either side fails, Dyad stops the other and exits with the failing status code. If both sides succeed, it exits zero. The options below change when a side is stopped, which status is reported, and whether the sides are connected with a pipe.
Note
In this documentation, success means a command exited with a zero status code and failure means it exited with a non-zero status code.
Specifying Commands
The special :: argument divides the left command from the right. Write commands just as you would without Dyad, with each command’s name as the first word, and any arguments it takes in subsequent words.
$ dyad run tail -f access.log :: tail -f error.log
(Don’t combine commands with arguments into a single string argument.)
Running Scriptlets
If you want to redirect input or output or run multiple commands on one side, you can write a scriptlet, an inline shell script wrapped in the command sh -c '...':
$ dyad run sh -c 'exec bin/server >>server.log' :: tail -f access.log
You must remember to exec into the scriptlet’s command. See Executing Into the Real Program.
Delimiting Options
You can pass -- before the left command to mark the end of Dyad’s own options. This is only necessary in the unlikely event your left side’s command starts with a dash.
However, you may find a superfluous -- makes a long Dyad invocation more readable, or a multi-line invocation more symmetric:
$ dyad run \
-- tail -f access.log \
:: tail -f error.log
Setting a Custom Separator
To change the :: separator to something else, set DYAD_SEP:
$ DYAD_SEP=@@@ dyad run ./left @@@ ./right
Choosing Which Exit Status to Report
Use -r/--report to control which side’s exit status Dyad reports:
| Status | Dyad exits with… |
|---|---|
last (default) |
the right side’s status if nonzero, otherwise the left side’s |
left |
the left side’s status |
right |
the right side’s status |
You can think of last as the “rightmost failure.”
Specifying the Stop Policy
When one side exits before the other, -s/--stop decides whether Dyad stops the side that remains:
| Policy | The remaining side is… |
|---|---|
on-failure (default) |
stopped only if the exiting side failed |
always |
stopped unconditionally |
no |
left alone; Dyad waits for it to exit |
Use always to bind a helper to a task. For example, to start a server, run a test suite against it, and tear the server down when the tests finish:
$ dyad run --stop always bin/server :: make test
About Stop Signals
Dyad always stops a side by sending it SIGTERM.
When Dyad itself receives SIGINT or SIGTERM, it forwards the signal to both sides as SIGTERM and waits for them to exit. See Signal Handling.
Connecting Sides With Channels
You can configure Dyad to open a channel, or a linked pair of file descriptors, that allows for unidirectional data transfer from the left side to the right.
Use the -c/--channel option to establish channels. Specify a single file descriptor number to link that descriptor on both sides, or a pair <LEFT-FD>:<RIGHT-FD> to link a different descriptor on each side. You can use file descriptors 0 through 6, and you can repeat the option to establish several channels at once.
Tip
A file descriptor is an integer that represents an open file in a process. Standard input is file descriptor 0, standard output is file descriptor 1, and standard error is file descriptor 2. Dyad uses file descriptors 7, 8, and 9 internally. POSIX shells are not required to support file descriptors greater than 9.
For example, you could use -c 3:0 to run ffmpeg configured to write its machine-readable progress reports to grep over file descriptor 3, leaving its log output on standard error:
$ dyad run -c 3:0 -- ffmpeg -i in.mp4 -progress pipe:3 out.mp4 :: grep out_time=
Piping Standard Output to Standard Input
You can replicate the behavior of the standard Unix shell pipeline using Dyad channels. A shell pipeline is also just a linked pair of file descriptors, where the standard output (on file descriptor 1) of the left side is connected to the standard input (on file descriptor 0) of the right side.
Add --pipe to connect the left side’s standard output to the right side’s standard input, like the shell’s | operator:
$ dyad run --pipe yes :: head -n 5
The --pipe option is equivalent to --channel 1:0:
$ dyad run --channel 1:0 yes :: head -n 5
You can combine --pipe with other --channel options. For example, you could use --pipe -c 3 to open an authentication channel on file descriptor 3 to send a password to an openssl filter without exposing the password on the command line or filesystem:
$ dyad run --pipe -c 3 \
-- sh -c "pass show backup >&3; exec tar cf - ~/Documents" \
:: openssl enc -aes-256-cbc -pbkdf2 -pass fd:3 -out backup.tar.enc
Note
When two channels use the same file descriptor on the same side, the last one specified takes precedence.
Using pipe and pipefail
Dyad provides shorthand syntax for the option combinations that match a shell pipeline.
dyad pipe works like the shell’s | operator: it connects the left side’s output to the right side’s input, lets each side run to completion, and reports the right side’s status. It is shorthand for dyad run --pipe --stop no --report right.
One use for dyad pipe is as a way to pass a pipeline to programs that wrap a command. For example, instead of sudo zfs send | sudo zfs receive, you could sudo dyad pipe:
$ sudo dyad pipe zfs send store/data@today :: zfs receive backup/data
dyad pipefail works the same as dyad pipe, except it reports the “rightmost failure” from either side, like a pipeline under Bash’s set -o pipefail. It is shorthand for dyad run --pipe --stop no --report last.
For example, make test | tee test.log hides test failures, because the pipeline exits with tee’s status rather than make’s. dyad pipefail reports the failure instead:
$ dyad pipefail make test :: tee test.log
Chaining More Than Two Sides
The right side of one Dyad invocation can be another Dyad invocation. This lets you chain three or more commands together. For example:
$ dyad run redis-server :: dyad run sidekiq :: bin/rails server
Chaining is possible because each Dyad invocation looks for the first :: argument before splitting commands into a left and a right side.
Each chained Dyad invocation specifies its own options. Consider a scenario where you want to run a test suite with two helper commands; if any side exits, so should the others, but you only care about the exit status of the test command. In this case, you can pass --report right and --stop always to both Dyad invocations:
$ dyad run --report right --stop always \
-- redis-server \
:: dyad run --report right --stop always \
-- sidekiq \
:: bin/rails test
The shorthand for this common set of options is dyad link:
$ dyad link \
-- redis-server \
:: dyad link \
-- sidekiq \
:: bin/rails test
If you want to chain Dyad invocations on the left side, or create a more elaborate tree structure of commands, you can invoke Dyad in a scriptlet:
$ dyad run \
-- sh -c 'exec dyad run redis-server :: sidekiq' \
:: bin/rails server
Executing Into the Real Program
Each side, like a “run script” under s6 and other daemontools-style supervisors, must execute into the long-lived process that is to be supervised. Most of the time you don’t need to think about this rule because the command you specify is the long-lived process. One common exception is a scriptlet, whose commands run under a new shell process:
$ dyad run sh -c 'exec redis-server >>tmp/redis.log 2>&1' :: sidekiq
You must remember to use exec here. It replaces the scriptlet shell with redis-server, so the server, not the shell, is the process Dyad supervises. Otherwise, when Dyad tries to stop the left side, the shell will terminate and the server will be orphaned.
In general, any program that runs its real work in a process other than the one Dyad launches must either execute into that process itself, or forward Dyad’s SIGTERM to it.
Contributing
Dyad is hosted on Codeberg: https://codeberg.org/sstephenson/dyad
We welcome issues and tested pull requests from human contributors. However, before submitting a large pull request, or one that changes behavior that is not a bug, we ask that you please open an issue first so we can discuss whether it is a good fit for the project.
About the Test Suite
Dyad’s tests live in the test/ directory; the test/*.brat files together comprise its test suite. The tests are written using Brat, a parallel TAP testing harness. Run them with:
$ vendor/brat -j 12 test/*.brat
Reporting Issues
Dyad is portable software and compatibility is a moving target. When reporting issues, please be sure to include information about your operating system, including its release version, and the version and lineage of the sh command.
Code Conventions
When contributing changes to Dyad, please respect the conventions of existing code in lib/dyad/ and libexec/.
Shell should be written with set -eu and careful consideration of what is specified by POSIX. See the Shell Command Language specification for more details.
License
Dyad is free software, distributable under the terms of the MIT + Trans Rights License. See LICENSE.md for details.
© 2026 Sam Stephenson. Handwritten in Mexico City.