-
Notifications
You must be signed in to change notification settings - Fork 0
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
| 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
|
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.
- the message is written to
<state>/outbox/(0600) before anything is typed; -
tmux load-bufferfrom a file — never a shell string, never an argv; -
paste-buffer -p(bracketed paste, so multi-line stays one message); - the daemon waits for the text to actually appear in the pane;
- only then Enter;
- 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.
-
The transcript comes from tailing
~/.claude/projects/*/<session-id>.jsonl. Turn boundaries come fromStop/Notificationhooks in a generatedsettings.jsonpassed 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.
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.
-
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.txtand passed as--append-system-prompt-file, so it survives a/compactinstead 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-promptopts out. -
The half that changes at runtime — the live roster, the token, the relay URLs, the whole of
MANUAL.md— stays in theSessionStart/UserPromptSubmithooks, because a system prompt is read once at startup and can never be rewritten.
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.
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.
$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.
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.
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.