SLOPSHOPPER

rails

Rails: lets you answer Claude's questions from Rails' chat as well as here

newguardnetwork
A shopper browsing a rack in a slop shop
README

Rails

A self-hosted, multi-device agent multiplexer. Attach a project folder, see every Claude Code, Codex and pi session that already exists for it, and run the agents' own terminal UIs side by side in panels you can split, resize and rearrange, from your laptop or phone.

Projects are added with your operating system's own folder picker (Finder on macOS, zenity/kdialog on Linux), which opens on the machine running Rails.

Requires Node 24+, tmux 3.2+ (every panel runs in it) and the agents' CLIs on the server's PATH. make dev, make start and make install check for tmux and install it with Homebrew, apt or dnf when it's missing (make system-deps does only that step). On Linux, node-pty compiles during npm install, so you also need python3, make and a C++ compiler.

With Nix you can skip all of that: nix develop gives a shell with the pinned Node, tmux and (on Linux) build tools from flake.lock; then use make as usual. The agent CLIs still come from your own PATH. If flakes aren't enabled in your Nix config, run nix --extra-experimental-features 'nix-command flakes' develop.

make dev               # installs deps if needed; server on :4317, UI on http://localhost:5173
# open the "open: …?token=…" URL printed in the terminal
make                   # list all targets

Production: make start, then open http://<host>:4317/?token=… (make token prints the token). Use make dev-lan, or set RAILS_HOST=0.0.0.0, to reach it from other devices (for example over Tailscale). The token is stored in ~/.rails/config.json.

Status:

  • Discovery for Claude Code, pi and Codex
  • Terminal panels running claude, pi, codex or a shell, backed by tmux so they survive server restarts and are shared across devices
  • Per-project split/tab layouts, saved on the server
  • Read-only git status and diffs
  • Official agent logos in the sidebar
  • Sprockets (ADR 0005, 0006): always-on agent characters that reply on your own model provider (API key or "Continue with ChatGPT"), attach to a project, follow a short description you write and see what every session in the project is doing (ADR 0007). Each has its own chat, a project's has the project chat (ADR 0021), and team chats put two to four of them in one conversation, passing each other the turn (ADR 0022, 0023)
  • A red dot when an agent needs you (a permission, a question) or its turn failed, and an amber mark when two sessions change the same files (ADR 0014)
  • Answers from the chat: the Coordinator's suggested answer to a pi question dialog (pi's own select and input, or rpiv-ask-user-question 2.13.9+), to Codex's question or to Claude Code's (2.1.287+, through Rails' mod) is sent with one tap (ADR 0027, 0028); other dialogs are answered in the panel
  • Agent tools (ADR 0018, 0030): Claude Code, pi and new Codex sessions can see what the other sessions in their project are doing, wait for one to finish, and draft messages for them, which you read and send
  • Session names (ADR 0019): rename a session from its menu in the sidebar or by double-clicking its tab, and Rails, the Coordinator and the agents call it by that name
  • Customize (ADR 0025): connectors (Linear, Notion, GitHub, Google Drive and more, added in one step and signed in to), other MCP servers and skills for your Sprockets (each tool allowed, asked first or off; a call that changes something waits on a card in the chat, where you run or skip it, ADR 0031), and saved prompts for Home's and Sprocket chats' composer
  • Automate (ADR 0032): a Sprocket runs a prompt on a schedule ("every weekday at 9") in its chat, with a run list; a run with nothing new posts nothing, and anything it wants to change waits on a card for you

Docs: architecture · glossary · decisions · provider formats · roadmap

Working on Rails with a coding agent? Start with AGENTS.md.

Source 1 files
hooks/register.js 55 lines
1// Rails' mod for Claude Code (ADR 0028). Rails loads it with `--plugin-dir` in the panels it starts (a copy in
2// RAILS_HOME, since Claude Code writes type files into a mod's folder). While Claude's question dialog
3// (AskUserQuestion) is open, it waits for an answer from Rails too: the user's own answer in the panel and one sent
4// from a Sprocket's chat race, and the first wins. An answer from Rails closes the dialog and reaches Claude as the
5// user's answer. Nothing else changes: without Rails (no RAILS_BRIDGE) the mod does nothing.
6
7export function register(on) {
8  on("tool.call", { tool: "AskUserQuestion" }, async ($, e, next) => {
9    const base = await $.env.get("RAILS_BRIDGE");
10    const token = await $.env.get("RAILS_PANEL_TOKEN");
11    const questions = Array.isArray(e.questions) ? e.questions : [];
12    // Only a single question with a known id: several are answered in the panel, as before.
13    const id = e.tool_use_id;
14    if (!base || !token || questions.length !== 1 || typeof id !== "string" || !id) return next(e);
15    const headers = { authorization: `Bearer ${token}`, "content-type": "application/json" };
16    const post = (path, body) => $.http.fetch(`${base}/claude/${path}`, { method: "POST", headers, body: JSON.stringify(body) }).catch(() => {});
17
18    const q = questions[0];
19    const options = (Array.isArray(q.options) ? q.options : []).map((o) => (typeof o?.label === "string" ? o.label : "")).filter(Boolean);
20    let settled = false;
21    // Tell Rails the dialog can be answered from it, by this tool call's id.
22    await post("dialog", { id, question: q.question, options });
23
24    const panel = next(e).then(
25      (result) => ({ from: "panel", result }),
26      (error) => ({ from: "error", error }),
27    );
28    const rails = (async () => {
29      // Rails holds each poll up to 25 s (a fetch may take 30), so this asks again until something settles.
30      while (!settled) {
31        let messages = [];
32        try {
33          const r = await $.http.fetch(`${base}/claude/inbox`, { headers });
34          if (r.ok) messages = JSON.parse(r.text || "{}").messages ?? [];
35          else await $.clock.sleep(2000);
36        } catch {
37          await $.clock.sleep(2000); // Rails is down or restarting: the panel can still answer
38        }
39        for (const m of messages) {
40          const mine = !settled && m?.answer?.dialog === id && typeof m.answer.text === "string" && m.answer.text.trim() !== "";
41          void post("delivered", { id: m?.id, ok: mine, ...(mine ? {} : { error: "The dialog has closed." }) });
42          if (mine) return { from: "rails", text: m.answer.text.trim() };
43        }
44      }
45      return { from: "none" };
46    })();
47
48    const won = await Promise.race([panel, rails]);
49    settled = true;
50    if (won.from === "rails") return { result: { questions, answers: { [q.question]: won.text } } };
51    if (won.from === "error") throw won.error;
52    return won.result;
53  });
54}
55