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
10 changes: 10 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
.git
.multiagent
.worktrees
InternalServices
benchmarks
evaluation
prod-mcp
patch.txt
**/node_modules
**/target
39 changes: 39 additions & 0 deletions .github/workflows/container.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: container

on:
push:
branches:
- main
workflow_dispatch:

permissions:
contents: read
packages: write

jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
- uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5
id: metadata
with:
images: ghcr.io/${{ github.repository_owner }}/multiagent
tags: |
type=raw,value=main,enable={{is_default_branch}}
type=sha,prefix=sha-
- uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
with:
context: .
push: true
tags: ${{ steps.metadata.outputs.tags }}
labels: ${{ steps.metadata.outputs.labels }}
platforms: linux/amd64
provenance: mode=max
sbom: true
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ evaluation/runs/
evaluation/reports/*
!evaluation/reports/.gitkeep
!evaluation/reports/swe-bench-pro-prod-multiagent-first50-summary.md
control-server/node_modules/
36 changes: 36 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
FROM rust:1-bookworm AS multiagent-builder

WORKDIR /src
COPY . .
RUN cargo build --release --locked

FROM node:22-bookworm-slim

ARG CODEX_VERSION=0.145.0
ARG CLAUDE_CODE_VERSION=2.1.239

RUN apt-get update \
&& apt-get install -y --no-install-recommends awscli ca-certificates git python3 tmux \
&& rm -rf /var/lib/apt/lists/* \
&& npm install --global "@openai/codex@${CODEX_VERSION}" "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}"

WORKDIR /opt/multiagent
COPY control-server/package*.json control-server/
RUN cd control-server && npm ci --omit=dev
COPY . .
COPY --from=multiagent-builder /src/target/release/multiagent /opt/multiagent/bin/multiagent
RUN chmod +x launch.sh bin/*.sh bin/*.mjs \
&& useradd --create-home --home-dir /var/lib/multiagent --uid 10001 multiagent \
&& mkdir -p /var/lib/multiagent/state /var/lib/multiagent/repositories \
&& chown -R multiagent:multiagent /var/lib/multiagent

USER 10001:10001
ENV HOME=/var/lib/multiagent/home \
CODEX_HOME=/var/lib/multiagent/codex \
CLAUDE_CONFIG_DIR=/var/lib/multiagent/claude \
MULTIAGENT_LAUNCHER_ROOT=/opt/multiagent \
MULTIAGENT_STATE_DIR=/var/lib/multiagent/state \
MULTIAGENT_REPOSITORY_ROOT=/var/lib/multiagent/repositories \
PORT=8080
EXPOSE 8080
ENTRYPOINT ["/opt/multiagent/bin/container-entrypoint.sh"]
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,39 @@ does not implement another coding agent or model loop. It runs Codex, Claude
Code, and Qwen Code in explicit roles, records durable workflow state, and
accepts work only when reviewer evidence matches the exact final Git diff.

## Stateful control server

The container image runs a same-origin web UI and authenticated WebSocket gateway as PID 1. Each task owns an isolated tmux orchestrator session. Paused, completed, and archived tasks retain their workflow state and terminal transcript without retaining an active tmux process; resuming reconstructs the session from that state.

Users are configured in a mounted JSON file. Passwords must be scrypt hashes, never plaintext:

```bash
node bin/hash-password.mjs operator
```

The mounted file has this shape:

```json
{
"sessionSecret": "at-least-32-random-characters",
"users": [
{"username": "operator", "passwordHash": "scrypt$16384$8$1$..."}
]
}
```

Task repositories must be provisioned as Git worktrees below `MULTIAGENT_REPOSITORY_ROOT`. Repository provisioning is owned by the deployment rather than the control server, so restarting the UI never fetches or mutates source checkouts.

Important container variables:

- `MULTIAGENT_USERS_FILE`: mounted login configuration, default `/run/secrets/multiagent/users.json`.
- `MULTIAGENT_REPOSITORY_ROOT`: deployment-provisioned Git worktrees available for new tasks.
- `MULTIAGENT_IDLE_TIMEOUT_SECONDS`: inactivity period after which a running task is checkpointed and paused.
- `MULTIAGENT_STATE_S3_URI`: S3 prefix used for recovery snapshots.
- `MULTIAGENT_PUBLIC_URL`: canonical HTTPS origin accepted for browser and WebSocket requests.

The PVC mounted at `/var/lib/multiagent` is the primary store for repositories, CLI conversation history, checkpoints, and session metadata. Final reports, a bounded terminal tail, and the transcript index live under each task's existing `logs` trace root. They reference immutable agent event traces instead of duplicating full transcripts. S3 is the durable recovery and inspection copy.

## Requirements

From a source checkout you need Rust 1.75+, Cargo, Bash, Git, and tmux. Install
Expand Down
12 changes: 12 additions & 0 deletions bin/container-entrypoint.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -euo pipefail
export HOME="${HOME:-/var/lib/multiagent/home}"

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why we need this ? I think the deployment side will start the multiagent process and then server start in parallel as a socket to connect to the tmux

export CODEX_HOME="${CODEX_HOME:-/var/lib/multiagent/codex}"
export CLAUDE_CONFIG_DIR="${CLAUDE_CONFIG_DIR:-/var/lib/multiagent/claude}"
export MULTIAGENT_STATE_DIR="${MULTIAGENT_STATE_DIR:-/var/lib/multiagent/state}"
export MULTIAGENT_REPOSITORY_ROOT="${MULTIAGENT_REPOSITORY_ROOT:-/var/lib/multiagent/repositories}"
mkdir -p "$HOME" "$CODEX_HOME" "$CLAUDE_CONFIG_DIR" "$MULTIAGENT_STATE_DIR" "$MULTIAGENT_REPOSITORY_ROOT"
if [[ -n "${MULTIAGENT_STATE_S3_URI:-}" && ! -f "$MULTIAGENT_STATE_DIR/control-server/sessions.json" ]]; then
aws s3 sync "$MULTIAGENT_STATE_S3_URI" "$MULTIAGENT_STATE_DIR" --only-show-errors || true
fi
exec node /opt/multiagent/control-server/src/server.mjs
17 changes: 17 additions & 0 deletions bin/hash-password.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
#!/usr/bin/env node
import crypto from "node:crypto";
import readline from "node:readline";

const username = process.argv[2];
if (!username || !/^[a-zA-Z0-9._-]{1,64}$/.test(username)) {
console.error("usage: bin/hash-password.mjs USERNAME");
process.exit(2);
}
const terminal = readline.createInterface({ input: process.stdin, output: process.stderr, terminal: true });
terminal.question("Password: ", (password) => {
terminal.close();
if (password.length < 12) { console.error("password must be at least 12 characters"); process.exit(2); }
const salt = crypto.randomBytes(16);
const hash = crypto.scryptSync(password, salt, 64, { N: 16384, r: 8, p: 1, maxmem: 64 * 1024 * 1024 });
console.log(JSON.stringify({ username, passwordHash: `scrypt$16384$8$1$${salt.toString("base64url")}$${hash.toString("base64url")}` }));
});
36 changes: 36 additions & 0 deletions control-server/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions control-server/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"name": "multiagent-control-server",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"start": "node src/server.mjs"
},
"dependencies": {
"ws": "8.21.3"
}
}
107 changes: 107 additions & 0 deletions control-server/public/app.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
const $ = (selector) => document.querySelector(selector);
let active = null;
let socket = null;
let sessions = [];

async function api(url, options = {}) {
const response = await fetch(url, { headers: { "content-type": "application/json", ...(options.headers || {}) }, ...options });
const body = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(body.error || `Request failed (${response.status})`);
return body;
}

function showLogin() { $("#login").hidden = false; $("#console").hidden = true; }
function showConsole(username) { $("#username").textContent = username; $("#login").hidden = true; $("#console").hidden = false; }

async function refreshSessions() {
sessions = (await api("/api/sessions")).sessions;
const target = $("#sessions");
target.replaceChildren(...sessions.map((session) => {
const button = document.createElement("button");
button.className = `session${active === session.id ? " active" : ""}`;
button.innerHTML = `<strong>${session.id}</strong><small>${session.live ? "live" : session.status} · ${session.repository}</small>`;
button.onclick = () => selectSession(session.id);
return button;
}));
if (active && !sessions.some((session) => session.id === active)) selectSession(null);
}

function selectSession(id) {
if (socket) socket.close();
active = id;
const session = sessions.find((candidate) => candidate.id === id);
$("#active-name").textContent = session?.id || "Select a session";
$("#active-repo").textContent = session?.repository || "";
$("#live-dot").classList.toggle("live", Boolean(session?.live));
const running = Boolean(session?.live);
$("#resume").disabled = !session || running;
$("#restart").disabled = !running;
$("#checkpoint").disabled = !running;
$("#pause").disabled = !running;
$("#complete").disabled = !running;
$("#archive").disabled = !session || running || session.status === "archived";
$("#message").disabled = !running;
$("#send").disabled = !running;
$("#report").textContent = "";
if (!session) { $("#terminal").textContent = "No orchestrator selected."; return refreshSessions(); }
api(`/api/sessions/${id}/report`).then(({ report, transcript }) => {
$("#report").textContent = report || (transcript ? `Trace references\n${transcript.traceReferences.join("\n")}` : "");
}).catch(() => {});
$("#terminal").textContent = "Connecting to orchestrator…";
const protocol = location.protocol === "https:" ? "wss" : "ws";
socket = new WebSocket(`${protocol}://${location.host}/api/sessions/${id}/terminal`);
socket.onmessage = ({ data }) => {
const message = JSON.parse(data);
if (message.type === "output") {
const terminal = $("#terminal");
const atBottom = terminal.scrollHeight - terminal.scrollTop - terminal.clientHeight < 80;
terminal.textContent = message.output;
$("#live-dot").classList.toggle("live", message.live);
if (atBottom) terminal.scrollTop = terminal.scrollHeight;
}
if (message.type === "error") $("#terminal").textContent += `\n[control error] ${message.error}`;
};
socket.onclose = () => $("#live-dot").classList.remove("live");
refreshSessions();
}

$("#login-form").onsubmit = async (event) => {
event.preventDefault();
const data = Object.fromEntries(new FormData(event.currentTarget));
try { const me = await api("/api/login", { method: "POST", body: JSON.stringify(data) }); showConsole(me.username); await refreshSessions(); }
catch (error) { $("#login-error").textContent = error.message; }
};
$("#logout").onclick = async () => { await api("/api/logout", { method: "POST" }); if (socket) socket.close(); showLogin(); };
$("#message-form").onsubmit = (event) => {
event.preventDefault();
const text = $("#message").value;
if (!text.trim() || socket?.readyState !== WebSocket.OPEN) return;
socket.send(JSON.stringify({ type: "input", text }));
$("#message").value = "";
};
for (const action of ["restart", "resume", "checkpoint", "pause", "complete", "archive"]) $("#" + action).onclick = async () => {
if (!active) return;
await api(`/api/sessions/${active}/${action}`, { method: "POST" });
await refreshSessions();
if (action === "restart" || action === "resume") selectSession(active);
};
$("#new-session").onclick = async () => {
const repositories = (await api("/api/repositories")).repositories;
const select = $("#create-form select");
select.replaceChildren(...repositories.map((name) => new Option(name, name)));
$("#create-dialog").showModal();
};
$("#cancel-create").onclick = () => $("#create-dialog").close();
$("#create-form").onsubmit = async (event) => {
event.preventDefault();
try {
const body = Object.fromEntries(new FormData(event.currentTarget));
await api("/api/sessions", { method: "POST", body: JSON.stringify(body) });
$("#create-dialog").close();
await refreshSessions();
selectSession(body.id);
} catch (error) { $("#create-error").textContent = error.message; }
};

try { const me = await api("/api/me"); showConsole(me.username); await refreshSessions(); } catch { showLogin(); }
setInterval(() => $("#console").hidden || refreshSessions().catch(() => {}), 5000);
58 changes: 58 additions & 0 deletions control-server/public/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Multiagent Control</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<div class="grain"></div>
<section id="login" class="login-shell">
<form id="login-form" class="login-card">
<p class="eyebrow">Movement Infrastructure</p>
<h1>Operator<br>Console</h1>
<label>Username<input name="username" autocomplete="username" required></label>
<label>Password<input name="password" type="password" autocomplete="current-password" required></label>
<button type="submit">Enter control room</button>
<p id="login-error" class="error"></p>
</form>
</section>

<main id="console" hidden>
<header>
<div><p class="eyebrow">Multiagent</p><h1>Task Control</h1></div>
<div class="operator"><span id="username"></span><button id="logout" class="quiet">Log out</button></div>
</header>
<div class="layout">
<aside>
<div class="aside-head"><h2>Tasks</h2><button id="new-session" title="New task">+</button></div>
<div id="sessions"></div>
</aside>
<section class="terminal-panel">
<div class="terminal-head">
<div><span id="live-dot" class="dot"></span><strong id="active-name">Select a session</strong><small id="active-repo"></small></div>
<div class="actions"><button id="resume" disabled>Resume</button><button id="restart" disabled>Restart</button><button id="checkpoint" disabled>Checkpoint</button><button id="pause" disabled>Pause</button><button id="complete" disabled>Complete</button><button id="archive" disabled>Archive</button></div>
</div>
<pre id="report"></pre>
<pre id="terminal">No orchestrator selected.</pre>
<form id="message-form">
<textarea id="message" rows="3" placeholder="Send an instruction to the orchestrator" disabled></textarea>
<button id="send" type="submit" disabled>Send message</button>
</form>
</section>
</div>
</main>

<dialog id="create-dialog">
<form id="create-form">
<p class="eyebrow">Bootstrap</p><h2>New task</h2>
<label>Task ID<input name="id" pattern="[a-z0-9][a-z0-9-]{0,62}" required></label>
<label>Repository<select name="repository" required></select></label>
<div class="dialog-actions"><button type="button" id="cancel-create" class="quiet">Cancel</button><button type="submit">Start orchestrator</button></div>
<p id="create-error" class="error"></p>
</form>
</dialog>
<script type="module" src="/app.js"></script>
</body>
</html>
Loading
Loading