Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 22 additions & 18 deletions registry/coder/modules/mux/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@ tags: [ai, agents, development, multiplexer]

# Mux

Automatically install and run [Mux](https://github.com/coder/mux) in a Coder workspace. By default, the module auto-detects an available package manager (`npm`, `pnpm`, or `bun`) to install `mux@next` (with a fallback to downloading the npm tarball if none is found). You can also force a specific package manager via `package_manager` and point to a custom registry with `registry_url`. The launcher keeps watching the mux process after startup, appends signal/exit-code diagnostics to the mux log when the server is killed outside the Node runtime, and can optionally wait a few seconds, remove the stale server lock, and restart Mux after any exit until an optional restart-attempt cap is reached. Mux is a desktop application for parallel agentic development that enables developers to run multiple AI agents simultaneously across isolated workspaces.
Automatically install and run [Mux](https://github.com/coder/xum) in a Coder workspace. By default, the module auto-detects an available package manager (`npm`, `pnpm`, or `bun`) to install `@coder/xum@next`, the npm package that ships the `mux` CLI (with a fallback to downloading the npm tarball if none is found). You can also force a specific package manager via `package_manager` and point to a custom registry with `registry_url`. The launcher keeps watching the mux process after startup, appends signal/exit-code diagnostics to the mux log when the server is killed outside the Node runtime, and can optionally wait a few seconds, remove the stale server lock, and restart Mux after any exit until an optional restart-attempt cap is reached. Mux is a desktop application for parallel agentic development that enables developers to run multiple AI agents simultaneously across isolated workspaces.

```tf
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.5.0"
version = "2.0.0"
agent_id = coder_agent.main.id
}
```
Expand All @@ -37,7 +37,7 @@ module "mux" {
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.5.0"
version = "2.0.0"
agent_id = coder_agent.main.id
}
```
Expand All @@ -48,10 +48,12 @@ module "mux" {
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.5.0"
version = "2.0.0"
agent_id = coder_agent.main.id
# Default is "latest"; set to a specific version to pin
install_version = "0.4.0"
# Default is "next"; set to a specific version to pin.
# Only versions published as @coder/xum are available: 0.28.3 or newer,
# or prereleases from 0.28.2-next.24.
install_version = "0.28.4"
}
```

Expand All @@ -63,7 +65,7 @@ Start Mux with `mux server --add-project /path/to/project`:
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.5.0"
version = "2.0.0"
agent_id = coder_agent.main.id
add_project = "/path/to/project"
}
Expand All @@ -78,7 +80,7 @@ The module parses quoted values, so grouped arguments remain intact.
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.5.0"
version = "2.0.0"
agent_id = coder_agent.main.id
additional_arguments = "--open-mode pinned --add-project '/workspaces/my repo'"
}
Expand All @@ -92,7 +94,7 @@ Enable automatic restarts after Mux exits, including clean exits and intentional
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.5.0"
version = "2.0.0"
agent_id = coder_agent.main.id
restart_on_kill = true
restart_delay_seconds = 3
Expand All @@ -106,7 +108,7 @@ module "mux" {
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.5.0"
version = "2.0.0"
agent_id = coder_agent.main.id
port = 8080
}
Expand All @@ -120,21 +122,21 @@ Force a specific package manager instead of auto-detection:
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.5.0"
version = "2.0.0"
agent_id = coder_agent.main.id
package_manager = "pnpm" # or "npm", "bun"
}
```

### Custom Registry

Use a private or mirrored npm registry:
Use a private or mirrored npm registry. The registry must serve the scoped `@coder/xum` package:

```tf
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.5.0"
version = "2.0.0"
agent_id = coder_agent.main.id
registry_url = "https://npm.pkg.github.com"
}
Expand All @@ -148,21 +150,21 @@ Run an existing copy of Mux if found, otherwise install from npm:
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.5.0"
version = "2.0.0"
agent_id = coder_agent.main.id
use_cached = true
}
```

### Skip Install

Run without installing from the network (requires Mux to be pre-installed):
Run without installing from the network (requires a `mux` binary at `<install_prefix>/mux`, by default `~/.coder-modules/coder/mux/mux`):

```tf
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.5.0"
version = "2.0.0"
agent_id = coder_agent.main.id
install = false
}
Expand All @@ -177,8 +179,10 @@ module "mux" {
- Mux is currently in preview and you may encounter bugs
- Requires internet connectivity for agent operations (unless `install` is set to false)
- Auto-detects `npm`, `pnpm`, or `bun` by default; set `package_manager` to force a specific one
- Requires a Node.js runtime; if `node` is not on the workspace `PATH`, the module bootstraps a pinned Node.js runtime into `~/.local/share/coder-mux` (override the version with the `MUX_NODE_VERSION` environment variable)
- Installs `mux@next` from the npm registry by default; set `registry_url` to use a private or mirrored registry
- Requires a Node.js runtime; if `node` is not on the workspace `PATH`, the module bootstraps a pinned Node.js runtime into `~/.coder-modules/coder/mux` (override the version with the `MUX_NODE_VERSION` environment variable)
- Installs `@coder/xum@next` from the npm registry by default (this package ships the `mux` binary); set `registry_url` to use a private or mirrored registry
- `install_version` must be a version or dist-tag published as `@coder/xum` (0.28.3 or newer, or a prerelease from 0.28.2-next.24); older releases were only published under the legacy `mux` package name
- Installs into `~/.coder-modules/coder/mux` and logs to `~/.coder-modules/coder/mux/logs/mux.log` by default, so the install survives restarts that clear `/tmp`; override with `install_prefix` and `log_path`
Comment thread
ibetitsmike marked this conversation as resolved.
- Falls back to a direct tarball download when no package manager is found
- Appends best-effort signal and external-kill diagnostics to `log_path` if the mux process dies after startup
- Set `restart_on_kill = true` to wait `restart_delay_seconds`, remove `~/.mux/server.lock`, and restart Mux after it exits
Expand Down
90 changes: 56 additions & 34 deletions registry/coder/modules/mux/main.test.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,44 @@
import { describe, expect, it } from "bun:test";
import {
executeScriptInContainer,
execContainer,
findResourceInstance,
readFileContainer,
removeContainer,
runContainer,
runTerraformApply,
runTerraformInit,
type scriptOutput,
type TerraformState,
testRequiredVariables,
} from "~test";

// Default install_prefix and log_path with HOME=/root inside the test containers.
const MODULE_ROOT = "/root/.coder-modules/coder/mux";
const LOG_PATH = `${MODULE_ROOT}/logs/mux.log`;

// Like executeScriptInContainer, but removes the container inside the test:
// deleting a container that holds a full @coder/xum install takes longer than
// the few seconds the global afterAll cleanup hook allows.
const executeInstallScriptInContainer = async (
state: TerraformState,
image: string,
before: string,
): Promise<scriptOutput> => {
const instance = findResourceInstance(state, "coder_script");
const id = await runContainer(image);
try {
await execContainer(id, ["sh", "-c", before]);
const resp = await execContainer(id, ["sh", "-c", instance.script]);
return {
exitCode: resp.exitCode,
stdout: resp.stdout.trim().split("\n"),
stderr: resp.stderr.trim().split("\n"),
};
} finally {
await removeContainer(id);
}
};

describe("mux", async () => {
await runTerraformInit(import.meta.dir);

Expand All @@ -23,10 +51,9 @@ describe("mux", async () => {
agent_id: "foo",
});

const output = await executeScriptInContainer(
const output = await executeInstallScriptInContainer(
state,
"alpine/curl",
"sh",
"apk add --no-cache bash tar gzip ca-certificates findutils nodejs && update-ca-certificates",
);
if (output.exitCode !== 0) {
Expand All @@ -36,9 +63,9 @@ describe("mux", async () => {
expect(output.exitCode).toBe(0);
const expectedLines = [
"📥 No package manager found; downloading tarball from registry...",
"🥳 mux has been installed in /tmp/mux",
`🥳 mux has been installed in ${MODULE_ROOT}`,
"🚀 Starting mux server on port 4000...",
"Check logs at /tmp/mux.log!",
`Check logs at ${LOG_PATH}!`,
];
for (const line of expectedLines) {
expect(output.stdout).toContain(line);
Expand All @@ -49,7 +76,6 @@ describe("mux", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
install: false,
log_path: "/tmp/mux.log",
additional_arguments:
"--open-mode pinned --add-project '/workspaces/my repo'",
});
Expand All @@ -62,16 +88,16 @@ describe("mux", async () => {
"sh",
"-c",
`apk add --no-cache bash >/dev/null
mkdir -p /tmp/mux
cat <<'EOF' > /tmp/mux/mux
mkdir -p ${MODULE_ROOT}
cat <<'EOF' > ${MODULE_ROOT}/mux
#!/usr/bin/env sh
i=1
for arg in "$@"; do
echo "arg$i=$arg"
i=$((i + 1))
done
EOF
chmod +x /tmp/mux/mux`,
chmod +x ${MODULE_ROOT}/mux`,
]);
expect(setup.exitCode).toBe(0);

Expand All @@ -83,7 +109,7 @@ chmod +x /tmp/mux/mux`,
expect(output.exitCode).toBe(0);

await execContainer(id, ["sh", "-c", "sleep 1"]);
const log = await readFileContainer(id, "/tmp/mux.log");
const log = await readFileContainer(id, LOG_PATH);
expect(log).toContain("arg1=server");
expect(log).toContain("arg2=--port");
expect(log).toContain("arg3=4000");
Expand All @@ -100,7 +126,6 @@ chmod +x /tmp/mux/mux`,
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
install: false,
log_path: "/tmp/mux.log",
});

const instance = findResourceInstance(state, "coder_script");
Expand All @@ -111,8 +136,8 @@ chmod +x /tmp/mux/mux`,
"sh",
"-c",
`apk add --no-cache bash >/dev/null
mkdir -p /tmp/mux
cat <<'EOF' > /tmp/mux/mux
mkdir -p ${MODULE_ROOT}
cat <<'EOF' > ${MODULE_ROOT}/mux
#!/usr/bin/env sh
target_pid="$$"
(
Expand All @@ -123,7 +148,7 @@ while true; do
sleep 1
done
EOF
chmod +x /tmp/mux/mux`,
chmod +x ${MODULE_ROOT}/mux`,
]);
expect(setup.exitCode).toBe(0);

Expand All @@ -135,7 +160,7 @@ chmod +x /tmp/mux/mux`,
expect(output.exitCode).toBe(0);

await execContainer(id, ["sh", "-c", "sleep 2"]);
const log = await readFileContainer(id, "/tmp/mux.log");
const log = await readFileContainer(id, LOG_PATH);
expect(log).toContain("shell exit code 137");
expect(log).toContain(
"SIGKILL usually means the process was killed externally or by the OOM killer.",
Expand All @@ -149,7 +174,6 @@ chmod +x /tmp/mux/mux`,
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
install: false,
log_path: "/tmp/mux.log",
restart_on_kill: true,
restart_delay_seconds: 1,
max_restart_attempts: 1,
Expand All @@ -163,10 +187,10 @@ chmod +x /tmp/mux/mux`,
"sh",
"-c",
`apk add --no-cache bash >/dev/null
mkdir -p /tmp/mux
cat <<'EOF' > /tmp/mux/mux
mkdir -p ${MODULE_ROOT}
cat <<'EOF' > ${MODULE_ROOT}/mux
#!/usr/bin/env sh
run_count_file="/tmp/mux-run-count"
run_count_file="${MODULE_ROOT}/run-count"
run_count=0
if [ -f "$run_count_file" ]; then
run_count=$(cat "$run_count_file")
Expand All @@ -186,7 +210,7 @@ else
fi
exit 0
EOF
chmod +x /tmp/mux/mux`,
chmod +x ${MODULE_ROOT}/mux`,
]);
expect(setup.exitCode).toBe(0);

Expand All @@ -198,8 +222,8 @@ chmod +x /tmp/mux/mux`,
expect(output.exitCode).toBe(0);

await execContainer(id, ["sh", "-c", "sleep 4"]);
const log = await readFileContainer(id, "/tmp/mux.log");
const runCount = await readFileContainer(id, "/tmp/mux-run-count");
const log = await readFileContainer(id, LOG_PATH);
const runCount = await readFileContainer(id, `${MODULE_ROOT}/run-count`);
expect(log).toContain("run=1");
expect(log).toContain("mux server exited cleanly.");
expect(log).toContain(
Expand All @@ -223,7 +247,6 @@ chmod +x /tmp/mux/mux`,
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
install: false,
log_path: "/tmp/mux.log",
restart_on_kill: true,
restart_delay_seconds: 1,
max_restart_attempts: 1,
Expand All @@ -237,10 +260,10 @@ chmod +x /tmp/mux/mux`,
"sh",
"-c",
`apk add --no-cache bash >/dev/null
mkdir -p /tmp/mux
cat <<'EOF' > /tmp/mux/mux
mkdir -p ${MODULE_ROOT}
cat <<'EOF' > ${MODULE_ROOT}/mux
#!/usr/bin/env sh
run_count_file="/tmp/mux-run-count"
run_count_file="${MODULE_ROOT}/run-count"
run_count=0
if [ -f "$run_count_file" ]; then
run_count=$(cat "$run_count_file")
Expand All @@ -253,7 +276,7 @@ if [ "$run_count" -eq 1 ]; then
fi
exit 0
EOF
chmod +x /tmp/mux/mux`,
chmod +x ${MODULE_ROOT}/mux`,
]);
expect(setup.exitCode).toBe(0);

Expand All @@ -265,8 +288,8 @@ chmod +x /tmp/mux/mux`,
expect(output.exitCode).toBe(0);

await execContainer(id, ["sh", "-c", "sleep 4"]);
const log = await readFileContainer(id, "/tmp/mux.log");
const runCount = await readFileContainer(id, "/tmp/mux-run-count");
const log = await readFileContainer(id, LOG_PATH);
const runCount = await readFileContainer(id, `${MODULE_ROOT}/run-count`);
expect(log).toContain("run=1");
expect(log).toContain("signal TERM (15); shell exit code 143.");
expect(log).toContain(
Expand All @@ -287,20 +310,19 @@ chmod +x /tmp/mux/mux`,
agent_id: "foo",
});

const output = await executeScriptInContainer(
const output = await executeInstallScriptInContainer(
state,
"node:20-alpine",
"sh",
"apk add bash",
);

expect(output.exitCode).toBe(0);
const expectedLines = [
"📦 Installing mux via npm into /tmp/mux...",
`📦 Installing @coder/xum via npm into ${MODULE_ROOT}...`,
"⏭️ Skipping lifecycle scripts with --ignore-scripts",
"🥳 mux has been installed in /tmp/mux",
`🥳 mux has been installed in ${MODULE_ROOT}`,
"🚀 Starting mux server on port 4000...",
"Check logs at /tmp/mux.log!",
`Check logs at ${LOG_PATH}!`,
];
for (const line of expectedLines) {
expect(output.stdout).toContain(line);
Expand Down
Loading
Loading