Skip to content

Architecture

Roy Padina edited this page Aug 29, 2026 · 2 revisions

Architecture

Three substrates, and nothing else: tmux for the real TUI, a WebSocket for the humans, and a tail of Claude Code's own JSONL transcript for what the agent said.

SPEC.md in the repo is the authority on the wire protocol, the frame shapes and the deliberate ceilings. This page is the map; it points at those sections rather than copying them.

        Roy (host)            Dana                 Eli
        client  ─────┐        client ────┐         client ────┐
                     └──────── WS ───────┴─────────────┘
                                  │
                     host.mjs daemon ── types into ──▶ the real `claude` TUI
                                  └── capture-pane ──▶ the live view everyone watches

The pieces

file what it is
claude-jam the launcher (bash). Owns the usage text and dispatches the subcommands. No arguments → menu.mjs
lib.mjs pure functions only — no fs, no spawn, no network. Nearly every decision in the project lives here, which is why the test suite is fast and large
host.mjs builds the tmux session, and is the daemon: WS server, frame pipeline, injection, the hooks endpoint, the relays
sessions.mjs tmux + fs lifecycle — list, end, clean, invite, remote. The only caller of kill-session
client-ink.mjs the real client. client-basic.mjs is the --basic readline fallback; client.mjs picks between them
menu.mjs the no-argument launcher. Builds argv and shells into claude-jam <subcommand> — it never re-implements one
xfer.mjs file transfer, both directions. Pure fs
platform.mjs the platform seam: the clipboard, notifications, sounds, $TMPDIR, ~/.config, owner-only writes. The only module allowed to spawn a platform binary
popup.mjs the one-key tmux display-popup approval
hooks.sh the Claude Code hooks, driven from a generated settings.json

The tmux session

claude-jam host builds a detached session with two windows — daemon (the log) and claude (the real TUI) — on a tmux server of its own: socket claude-jam-<port>.

That socket is not a detail. It is what lets claude-jam bind a bare F3 to detach-client without touching your tmux configuration, and it makes the ownership rule structural: list-sessions on that socket cannot return one of your sessions even in principle. --tmux-socket default puts it back on the shared server, and then the F3 binding is skipped because there it would be global.

Injection: how a message reaches claude

  1. the message is written to <state>/outbox/ (0600) before anything is typed;
  2. tmux load-buffer from a file — never a shell string, never an argv;
  3. paste-buffer -p (bracketed paste, so multi-line stays one message);
  4. the daemon waits for the text to actually appear in the pane;
  5. only then Enter;
  6. the outbox entry is deleted once the input box is seen to empty.

A pty drops what a busy TUI does not read in time (measured: an 8 KB paste into a redrawing pane arrived 4.2 KB short, silently). So pastes go in as 2 KB pieces, each checked against the count in claude's own [Pasted text +N lines] marker; a short piece is treated as a truncation and the whole message is kept rather than half-sent.

If the folder has never been trusted, claude shows its trust dialog. Before every injection until one succeeds, the daemon waits up to 30 s for either that dialog (it answers it, moving off the "No, exit" default first) or the input prompt — so a message sent while claude is still booting still lands.

Output: two channels

  • The transcript comes from tailing ~/.claude/projects/*/<session-id>.jsonl. Turn boundaries come from Stop / Notification hooks in a generated settings.json passed with --settings, so nothing global is touched. All JSONL parsing is in one function (parseJsonlLine) — Claude Code's format is officially unstable, so a change is a one-place fix.
  • The live view is tmux capture-pane -e, only for clients that asked for it, never stored.

At boot the daemon seeds its 300-event history ring from the transcript already on disk, so the first guest to join a --resumed session gets the conversation rather than a blank room.

The frame pipeline

An adaptive cadence, because a mirror is a poll and a poll costs a capture-pane per tick:

state interval
somebody watching and something moved in the last 2 s 40 ms
watching, but quiet 250 ms
nobody watching no polling at all

Capped at 25 frames a second per client, and an unchanged screen sends nothing. Each frame is sanitized row by row, fitted to that client's own terminal size, and masked on the way out.

What claude is told, split by lifetime

  • The durable half — that the session is shared, that [Name]: is who is talking, the two rules that must never decay, and a short digest of how a jam works — is written to <state>/system-prompt.txt and passed as --append-system-prompt-file, so it survives a /compact instead of being summarised away. claude-jam probes for the flag before using it (it works on claude 2.1.251 but is absent from --help) and falls back to hooks-only with one log line if a build rejects it. --no-system-prompt opts out.
  • The half that changes at runtime — the live roster, the token, the relay URLs, the whole of MANUAL.md — stays in the SessionStart / UserPromptSubmit hooks, because a system prompt is read once at startup and can never be rewritten.

The approval ladders

Five kinds, one shape: knock, cmd, export, file, permission. Default deny, one request in flight per person, a two-minute expiry, always living only in daemon memory. Every one of them surfaces the same way — a one-row approval bar above the host's status row, answerable with one key on an empty input line, plus a tmux display-popup for anyone attached to the session. Whoever answers first wins.

Control endpoints

Loopback-only HTTP on the jam's own port, each requiring the hook secret out of the 0700 state dir: POST /end, POST /invite, POST /remote, POST /admit. GET /health is public because the launcher polls it. That is how claude-jam end, claude-jam invite and claude-jam remote reach a running daemon — the same code path the in-client commands use, so the two surfaces cannot drift.

State on disk

$TMPDIR/claude-jam-<port>/, mode 0700, containing session.json (the ownership half), token.json, roster.json, the generated settings.json, system-prompt.txt, the invite store (hashes only) and the outbox. The directory name is a safety rule in itself: claude-jam clean will only ever delete a directory whose basename parses back to a port, and only when its session is provably gone.

The platform seam

Everything that is only true of one operating system lives in platform.mjs: clipboardImage(), notify(), playSound(), stateDir(), configDir(), historyFile(), secureWrite(), openExternal(), copyText(). It is the only module allowed to spawn a platform binary, and a unit test enforces that. tmux, claude, git, curl, cloudflared, tailscale and ttyd are not platform binaries — they are dependencies, spelled the same everywhere.

Today every implementation behind that seam is the macOS one. There is no Windows implementation; the seam exists so that adding one is a branch inside nine functions rather than a search across the codebase.

Tests

node --test test.mjs — 389 tests, all against the pure functions in lib.mjs, all under a second. Four of them are lints: no user-visible string may print a bare jam command form; no module outside platform.mjs may spawn a platform binary; --help and /menu must name the same host flags; and MANUAL.md must name every command the tool has.

Eighteen end-to-end smokes live in scripts/, with the full recipe in SPEC.md under "Running the end-to-end smokes". Six need a daemon of yours and run in a fixed order; a seventh needs a knock-only one. The remaining eleven bring their own everything, and all but one of those (smoke-perm.mjs, which drives a real permission prompt) need no claude at all — the pane is a stand-in, and smoke-answer.mjs paints the real tmux capture-pane captures from fixtures/pane/ into it, so tmux, the daemon and both wire protocols are real while only claude's redraw is imitated. smoke-peer.mjs does the same for the peer executor (scripts/fake-claude.mjs), which is what makes the trust-boundary assertions facts on disk rather than claims.

The full sweep is the release gate: a red suite stops the release, and is never released over.

Next

Security Model · Hosting a Jam · Troubleshooting

Clone this wiki locally