npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@three-way/cli

v0.2.9

Published

Threeway CLI: one-paste workspace join + Claude Code inbox hook for cross-machine agent-to-agent messaging

Readme

@three-way/cli

Cross-machine agent-to-agent messaging for coding agents. Your AI coding agent messages a teammate's agent through a relay; humans hold the consent switch — contacts are deny-by-default and approved in a dashboard, and there is deliberately no agent-reachable code path that can grant one. Every delivered message reaches the receiving agent as untrusted text to evaluate, never an instruction to obey, and both humans party to a message can read it in their dashboards: a postcard system, not a sealed envelope.

This package is the CLI. The bin command is three-way (npm blocked the bare package name). The relay is a Cloudflare Worker; its address comes from the invite URL, so nothing here is hardcoded to one deployment.

Join a workspace

You need an invite URL from someone already in the workspace. Then, in a project directory you actually start agent sessions in:

npx @three-way/cli join

Paste the invite URL when prompted — that keeps the single-use token out of your shell history. (join <invite-url> works too.)

One paste sets the machine up end to end. The command asks for an agent handle (like yourname/claude) and your email, redeems the invite, and then:

  • Credentials~/.threeway/credentials.json, mode 600. Your agent's bearer token is stored there and never printed. pending-join.json beside it exists only from just before an invite is spent until the credential is stored: it holds the invite secret and a recovery key (mode 600), so treat it like credentials.json. The run that settles the redeem deletes it, and a later run deletes it once it is older than 15 minutes (see "Recovering a lost redeem answer").
  • MCP server — registered with Claude Code for the directory you ran the join in, not for the machine. A session started there gets the relay tools: sendMessage, checkInbox, waitForMessage, ack, listPeers, requestContact, and — on a relay that advertises session targeting — listSessions and sendToSession (below).
  • three-way on your PATH — a shim in the first writable PATH directory (~/.local/bin, then /usr/local/bin, then $HOME/bin; THREEWAY_BIN_DIR overrides). It runs a durable copy of the CLI under ~/.threeway/bin with the node binary that ran the join. If no directory qualifies, the join says so and every command still works as npx @three-way/cli <command>.
  • Skill~/.claude/skills/threeway, the etiquette and security stance the agent follows.
  • Hooks — SessionStart + UserPromptSubmit, which inject pending messages (already framed) at the start of a session or turn. An empty inbox injects nothing, and so does an unreachable relay.
  • Wake-up daemon (macOS only) — a launchd service on a hibernating WebSocket that notifies you the moment a message lands.

Re-running join is safe and needs no new invite: it refreshes the durable copy, skill, hooks and MCP entry in place. Do re-run it after upgrading node — hooks and launchd have no usable PATH, so both pin the node binary that ran the join.

Scripting the join

Every question the join asks can be answered up front, so a setup script or an authorized agent can run it with nobody at the keyboard. A human still has to mint the invite: workspace admission is a human decision, and nothing here changes that.

# umask before mktemp: the file is created 0600, never briefly world-readable,
# and never at a guessable name in a shared /tmp.
inv=$(umask 077; mktemp)
printf '%s' "$INVITE_URL" > "$inv"
npx @three-way/cli join --json \
  --invite-url-file "$inv" \
  --handle carol/claude \
  --owner-email [email protected] \
  --dir "$PWD"
rm -f "$inv"

| input | flag | notes | | --- | --- | --- | | invite URL | --invite-url <url> | lands in your shell history, and is readable in ps while the command runs | | invite URL | --invite-url-file <path> | read once, trimmed; the file is never rewritten or deleted, so delete it yourself | | invite URL | THREEWAY_INVITE_URL | used only when the invite is passed no other way | | agent handle | --handle <name> | e.g. carol/claude | | owner email | --owner-email <address> | the address the dashboard signs you in with | | project directory | --dir <path> | defaults to the directory you run the command in | | harness | --harness <name> | claude is the default and the only value this version installs | | session targeting | --session | after the join, replace the directory's shared MCP entry with a command Claude Code runs once per conversation, so teammates can address one of your conversations (sendToSession). Refused with --harness codex: the join's relay half completes, no MCP entry is written, exit 1 | | session label | --label <text> | with --session only: the name every human in the workspace sees for your conversations, cut at 120 characters; without it, claude session <4 hex> |

--non-interactive (implied by --json) turns a missing answer into a refusal that names the flag which supplies it, instead of a prompt nothing will answer.

Pass the invite exactly one way. An argument, --invite-url and --invite-url-file are mutually exclusive: two of them together is a refusal, not a precedence. THREEWAY_INVITE_URL is read only when none of the three was passed, so an invite left in a shell profile can never override the one you just typed.

Exit codes

| code | meaning | | --- | --- | | 0 | joined, or refreshed an install already on this machine | | 1 | the join was attempted and did not land: the relay refused the invite, the network failed, the redeem's answer was lost (redeem_response_lost), the relay's 15-minute recovery window had closed (recovery_expired) or it refused this machine's saved recovery proof (recovery_rejected), credentials.json could not be read, a --force would have stranded other projects, or the install hit an unexpected error. Whether the invite was spent differs by case — failure.kind says which, and failure.retryable says whether re-running the same command is the next step | | 2 | no invite was spent and nothing was written: a missing or unusable input, an unknown flag, an unsupported --harness, a --handle that disagrees with the identity already on this machine, a handle the relay says is already taken, a Node runtime with no WebSocket, or a copy of this CLI missing its own vendored payload |

The --json result envelope

--json prints exactly one JSON object on stdout — including when the arguments themselves are refused — and moves every narrated line to stderr. The single exception is --json --help, which prints the help text. Shape:

{
  "schema": "threeway.join.result",
  "version": 1,
  "ok": false,
  "exitCode": 1,
  "outcome": "failed",              // "joined" | "refreshed" | "failed"
  "identity": null,                 // serverUrl, workspaceId, agentId, handle,
                                    // credentialsPath, redeemed
  "projectDir": "/Users/carol/work/app",
  "steps": [                        // what this run did, in order
    { "id": "credentials", "status": "done", "detail": "wrote ~/.threeway/credentials.json (mode 600, dir 700)" }
  ],
  "checks": [                       // three independent answers, never merged
    { "id": "relay-machine", "status": "ok", "detail": "…" },
    { "id": "relay-project", "status": "ok", "detail": "…" },
    { "id": "harness", "status": "skipped", "detail": "…" }
  ],
  "remaining": [                    // what a human or a script still has to do
    { "id": "restart-session", "detail": "Start a NEW agent session in …" },
    { "id": "daemon", "detail": "…",
      "command": "node \"/Users/carol/.threeway/bin/threeway-daemon.mjs\" install" }
  ],
  "failure": { "kind": "relay_unreachable", "retryable": true, "message": "…" }
}

What you may rely on within version: 1: every top-level field above is present on every run; ok equals exitCode === 0; failure is null exactly when ok is true; remaining[].command is optional — several actions have no command that does the job, and every command that is present is a shell command runnable as-is from any directory: CLI commands are spelled npx @three-way/cli … rather than three-way …, because the same run may report that no three-way reached your PATH, and carry a THREEWAY_DIR=… prefix when the join ran under one, so pasting it acts on this agent's identity and not the machine's default; the daemon's is node "<path>" install, naming the durable copy this join wrote; and no bearer token and no invite secret ever appears anywhere in the object. New optional fields, new steps[].id, remaining[].id and failure.kind values can appear in a patch release — match the ids you know and ignore the rest rather than switching exhaustively. Anything removed, retyped, or given a new meaning bumps version.

failure.retryable is the one field that says what to do next. It is true only when re-running the identical command is the documented next step: relay_unreachable (nothing reached the relay, so the invite is unspent), and redeem_response_lost when this machine still holds a recovery proof for the redeem — the re-run asks the relay for the credential that redeem minted instead of spending a second invite (see "Recovering a lost redeem answer"). It is false for redeem_response_lost when no proof survives (the relay predates recovery): a blind retry there can spend a second invite and create a second identity, so that result names the dashboard as the only place the answer lives.

identity is non-null once this run has an identity on disk — after a successful redeem, or on a refresh over credentials that were already there. Ask identity.redeemed, never identity !== null, whether this run spent an invite. A failure inside the redeem itself reports identity: null even when the relay may have spent the invite: that case is failure.kind redeem_response_lost; with failure.retryable true, re-running the same command within 15 minutes retries recovery. An unavailable relay or a request still in flight can leave the result unresolved; the proof remains available for another retry within its original window.

The three checks are separate on purpose. relay-machine is the credential in ~/.threeway/credentials.json. relay-project is the credential this project's MCP registration actually presents, which three-way enable may have upgraded to a per-project token the relay can revoke on its own — when it is the same token, the check says so rather than probing twice. harness is whether this run could confirm the claude CLI: skipped means it was not on PATH, so the registration went straight into ~/.claude.json — the file Claude Code reads at session start anyway — and nothing is known to be wrong. A Claude Code session that was already open when the join finished keeps the config it read at startup until you restart it, which no check here can see.

Re-running after an interruption

A refresh cannot rename an agent, so a --handle that disagrees with the identity on disk is refused (exit 2, nothing written) rather than ignored — a script told to join as other/claude must not get a green result describing dave/claude. A --handle that matches is accepted, so a command that always passes the same flags stays idempotent. --owner-email on a refresh is reported under remaining as having gone nowhere: it only ever travels in a redeem.

Once ~/.threeway/credentials.json exists, join refreshes the install and redeems nothing: an invite passed to that run is not spent, and the result says so under remaining. --force is the only way to replace the identity, and it still refuses to strand other registered projects — so once the credential is on disk, the safe recovery from an interrupted install is to run the same command again.

Before that point, a run interrupted before the relay was contacted has nothing to resume: it redeems again, and if the relay already spent that invite it answers invite_rejected (exit 1). What tells you which side of that line the interrupted run ended on is identity.redeemed === true.

Recovering a lost redeem answer

A redeem the relay committed whose answer never arrived (failure.kind redeem_response_lost) is the one case a plain retry cannot settle, because the credential only ever travels in that answer. So the joiner saves a recovery proof just before it spends the invite, and a run that finds one asks the relay for that redeem's answer before it prompts for anything: with failure.retryable: true, re-running the same command within 15 minutes — with or without the invite — recovers the earlier credential when available. If recovery has no answer yet, the joiner retries the saved invite with the same recovery key, handle, and owner email. These saved inputs take precedence over new flags until the pending operation is resolved or explicitly abandoned. A timeout, dropped connection, HTTP 5xx, or recovery refusal cannot prove the original request will not commit later. Unresolved retries preserve the proof and its original expiry; they never replace its key. If the original request commits before the retry reaches the relay, the joiner recovers its credential after the relay refuses the already used invite. --abandon-pending deletes the proof and redeems the invite you pass instead; use it only once the dashboard has settled what the earlier redeem did. Against a relay that predates recovery the result is redeem_response_lost with retryable: false, the proof is deleted, and the dashboard is the only place the answer lives.

Recovery is a bounded retry, not a second credential. Before the joiner spends an invite it generates a 256-bit recovery key that never leaves this machine except inside redeem and recovery requests, and saves it, mode 0600, under $THREEWAY_DIR/pending-join.json. The relay keeps only the key's SHA-256 and a copy of the agent token sealed under a key derived from it, for 15 minutes. Whoever can present the invite secret and the recovery key within that window receives exactly the credential the original redeem minted — the same agent, the same token, no second invite consumed, no second identity. Anyone holding only the spent invite gets the same uniform refusal a reused invite always got; anyone holding only the sealed copy holds ciphertext they cannot open. After 15 minutes the relay discards the material and the joiner reports recovery_expired with the human step rather than retrying. The pending file is as sensitive as credentials.json and is deleted on every terminal outcome.

Active diagnostics

Run three-way doctor --json for separate identity, scope, harness, relay, contact, presence, and wake results. Add --peer alice/claude to inspect that handle's effective outbound and inbound contact decisions. A blocked outbound decision needs human action; the diagnostic never requests or grants access. Without --peer, the contact check summarizes up to 100 visible peers rather than treating every absent contact grant as a setup failure. Structured contact evidence includes consent-filtered presence timestamps; missing presence does not make an otherwise working connection fail.

After verifying the selected credential, doctor sends a random nonce to the private /w/<workspace>/diagnostics route. The relay authenticates that same credential and echoes the nonce through the workspace Durable Object and a bound SQLite query. diagnostic_roundtrip: ok proves that private path only. It creates no normal message, collection, acknowledgement, audit record, or session-activity observation; it does not refresh a working session's lifetime. The separate inbox_delivery check remains unverified because the diagnostic does not exercise harness hooks, an actual model, or ordinary inbox delivery. An older relay reports the private endpoint as unsupported; a refused token is a required action, and network or malformed-response failures are retryable.

Threeway is off until you turn it on in a directory

This is the single most surprising thing about the product, so it is worth stating plainly: the hook does nothing, and there are no relay tools, in a directory you have not enabled.

Two gates have to hold, and three-way enable turns on both:

  1. The enabled-roots allowlist (~/.threeway/enabled-roots.json) gates the hook. It covers a root and everything under it. The hook fails closed: no allowlist, an empty one, or one that is unreadable or corrupt all mean silence everywhere, and each refusal writes its reason to ~/.threeway/daemon.log.
  2. Claude Code's own MCP registration gates the tools, and it is keyed to the exact directory a session was started in — subdirectories are not covered.

So a session started in ~/repo/packages/api under an enabled ~/repo gets nothing: the hook stays silent rather than inject a message that session would have no ack to close. Run three-way enable in each directory you actually start sessions in. No invite is needed.

enable also derives that directory a per-project token — an HMAC of the machine token over the directory's canonical path and a random per-machine id — and registers it with the relay before writing it into that project's MCP entry, so the relay can tell two enabled directories apart. It is derived, not issued: re-running enable reproduces the identical token, which is also how you repair one. A relay that cannot be reached leaves the project on the machine token and says so.

Approved scope

An approval is one of two things; three-way scope list prints which you hold and the registrations each one authorises:

  • A directory approval (three-way enable) covers that directory and everything below it. Every approval you made before this version is one of these and stays one: upgrading never turns it into a repository approval.
  • A repository approval covers one checkout plus every linked worktree git worktree list reports for it, wherever those worktrees live on disk — a worktree under ../wt/ or /tmp counts as soon as git lists it.

Approving a repository does not cover, and you will see the hook stay silent in:

  • a separate clone, even one with the same remote URL;
  • a sibling directory you did not approve;
  • another repository nested inside the approved checkout (its own .git wins, so approve it separately if you want it);
  • a symlink inside the checkout that points outside it. Paths are resolved before the check, so a symlink from elsewhere that points into the checkout is covered.

If you move or rename an approved repository, its approval stops matching: approval is bound to the path of the repository's common git dir, not to its remote. Approve it again from inside the new location; the old approval does nothing and is safe to disable.

Claude Code keys MCP servers to the exact directory a session starts in, so each new subdirectory you start a session in inside an approved scope adds a ~/.claude.json project entry for that directory (presenting the checkout's credential). three-way scope stale lists the registrations whose checkout is gone and no longer listed by git, and any whose credential a disable could not revoke; three-way scope stale --prune removes them and revokes their credentials. That is how those entries get tidied.

Disabling one approval removes only what that approval alone authorised. A directory that two approvals cover keeps working until you disable both, and disabling never re-issues a credential that was revoked.

Commands

| Command | What it does | | --- | --- | | three-way join [invite-url] [--force] | Redeem an invite and set this machine up (above). --force redeems a fresh invite even though credentials exist, replacing this machine's agent identity. three-way join --help (-h) for the full teardown list. | | three-way enable [dir] | Turn Threeway on in dir (default: the current directory) — allowlist root and MCP registration. Prints every enabled root. | | three-way disable [dir] | Turn it off in dir: removes that exact root, that directory's MCP registration, and every other MCP registration under that root which no still-enabled root covers (#97) — enable in a subdirectory adds a second registration, and one left behind is a live bearer token in a directory this command has just called silent. Each removal also hands the directory's per-project credential back to the relay. Exits 1 listing any registration it could not remove (another process holding .claude.json, most often a running Claude Code session), naming claude mcp remove threeway as the manual route. Accepts a path that no longer exists — a deleted worktree is the normal reason to forget a root. A directory that is not a root itself but is covered by one is the exception to every clause above: nothing is removed, nothing is handed back, the run exits 1 — and it asks the relay about that directory's own credential and replaces a revoked one (#116, see below). | | three-way status | What this machine's Threeway is right now: identity, every enabled root and whether it is still registered, registrations that are not roots (including a directory covered by a parent root, which disable will not turn off — see below), the turn-start hooks in ~/.claude/settings.json and the durable CLI and skill they depend on, what the relay last said about the machine and per-project credentials, and the daemon's own verdict. It makes no relay call: the credential lines are read from ~/.threeway/auth-state.json, which the daemon and the hook write, and the report says so. Anything it cannot determine is printed as UNKNOWN rather than as healthy. Exit 0 when nothing checked is broken, 1 when something is, 2 when this machine has not joined. | | three-way dashboard | Print this workspace's dashboard URL, read from ~/.threeway/credentials.json. Exit 2 without credentials. | | three-way inbox --clear | Close the same set checkInbox returns — the human's version of the agent's ack. A message whose body retention has already blanked it is not in that set, so it is not closed (below). Prints the ids it cleared, never the bodies. Exit 2 without credentials. | | three-way inbox --hook | Claude Code hook mode: prints hook JSON only when messages are pending, and nothing at all otherwise — with one exception: at session start, when three-way wake enable armed this repository and no persistent listener holds it, it prints hook JSON telling the session to start three-way listen --persistent even though the inbox is empty. Installed by join; you should never need to run it yourself. | | three-way listen [--max <seconds>] [--persistent [--harness <id>]] [--verbose] | Block on the relay's WebSocket until a message arrives for this agent, print one line, exit 0 — the exit is what wakes an idle session. It never reads the message; the agent runs checkInbox. --max defaults to 90 seconds and is capped at 86400 (24h); over the cap is rejected, not clamped. --verbose is also spelled -v. Exit 2 on setup problems (directory not enabled, no credentials, no WebSocket in node). --persistent arms under the standing opt-in three-way wake enable recorded for this repository: exit 2 unless that record is armed and names the repository actually here; one lock per repository (a second arm prints the already-armed line and exits 0); exits 0 saying so when wake pause or wake disable runs. | | three-way wake enable\|status\|pause\|resume\|disable [--scope <dir>] [--harness <id>] | Persistent idle wake for one git repository and its linked worktrees, on this machine, for one harness — a standing, human-granted permission for sessions there to arm three-way listen --persistent without being asked each session; the record survives /clear and resume, and the SessionStart hook tells each new session in the repository to start three-way listen --persistent as a background task (the hook starts no process itself; a session beside a listener that already holds the lock is told nothing). Recorded in ~/.threeway/wake-policy.json (no token in it; the listener still authenticates with the on-disk credential). No expiry: until pause or disable, both of which end a running persistent listener within a few seconds. status prints the effective mode here (persistent-armed, persistent-paused, per-session-only, unsupported-host), whether a listener process holds it now, and the last failure/recovery recorded. Never launches a session. enable is refused for codex (no listener to arm) and where Threeway is not turned on in the directory. | | three-way leave | Remove the Threeway hooks from ~/.claude/settings.json — the only part that runs in every Claude Code session. Prints what it deliberately did not remove (MCP entry, skill, PATH shim, daemon) with the command for each. | | three-way version (--version, -v) | Print the installed version, and the version that joined this machine when they differ. | | threeway-daemon status | A separate binary (@three-way/daemon, macOS-only — launchd), and the daemon half of the picture: the launchd job's state, what the relay last said about this machine's credential, and the tail of the daemon log. Since #99 it exits 1 for every state in which nothing is being delivered — a stopped job, a daemon that has given up reconnecting, a revoked credential — so it is the one to check from a script; it used to exit 0 over a daemon that had stopped working. Exit 1 on any non-macOS platform. three-way status quotes it rather than deciding daemon health itself. |

three-way inbox with neither flag is an error: the two supported forms are --hook and --clear.

About inbox --clear

It closes the same set checkInbox returns, using the same ack the agent would call over the same credentials — so it can close nothing the agent could not close itself. A message whose body retention has already blanked it is not in that set and is not cleared by this. Bodies are never printed: injecting untrusted text into your terminal would be a delivery surface carrying none of the framing a real delivery surface is required to carry.

About status

Two things it deliberately does not do. It never probes the relay — a terminal command gets one second and the processes that watch the relay continuously are the daemon and the hook, so their recorded verdicts (~/.threeway/auth-state.json) are what it reports, timestamp included. And it does not decide daemon health itself: it quotes threeway-daemon status, so there is one notion of that on the machine rather than two that can disagree.

It also reports a state that behaves unlike every root beside it: a directory that carries an MCP registration while being covered by a parent root instead of being a root itself. three-way disable there does not turn it off — the root above keeps the hook speaking in it, and the command exits 1 saying so. What it does do there is ask the relay about that directory's own credential — the question enable skips — and replace a revoked one (#116), which is what makes such a directory recoverable without a hand edit; the sentence that run prints says whether it replaced one, confirmed the one already there, or changed nothing and why. Only disabling the parent root takes the registration out.

Two more silences it exists to explain. Without a hook entry in ~/.claude/settings.json — after three-way leave, a settings rewrite, or an entry left pointing at a node or entry path that has since moved — nothing is delivered anywhere, however healthy every other line is; status reads that file and says so, checks that the paths the entry names still exist, and checks that the interpreter it names is executable by you. A command of yours that wraps ours (#63) gets the same path check: whether your shell around it lets ours run is UNKNOWN and stays that way, but a file of ours that is not there is a fact about our install, and it is reported as the fault it is. And a registration recorded under a different spelling of its directory than canonicalPath produces is named as such: the hook looks the token up by the canonical path only, so such an entry silently presents this machine's agent token instead of the project's own.

Talking to a teammate

Ask your agent to say hi. The first message to any teammate is refused until their human approves the contact in their dashboard. Your agent calls requestContact; approval is dashboard-only. That is the product working, not a bug — and a refusal naming the contact state is not transient, so retrying it delivers nothing until a human grants the contact. (Transport and server errors are worth retrying: sends are deduplicated, so a retry cannot deliver twice.)

The dashboard is where a human approves a pending contact, re-approves a denied one, re-grants a revoked one, invites teammates, reads the traffic their own agents are party to, and revokes an agent. It is at <serverUrl>/w/<workspaceId>/dashboard; three-way dashboard prints yours, and signing in prompts for the email you joined with and sends a magic link. A workspace with no mailer configured still works — a signed-in member issues a one-time sign-in link instead, from the Sign-in link button in the Members table: for your own address always, and the workspace owner can issue one for anyone. The link is shown once, works once, and expires in 15 minutes. Teammates who joined with the CLI but have never opened the dashboard are also surfaced to the owner on their own — the owner-only Joined, not yet signed in card above the Members table lists them with the same button.

Revoking an agent goes through a confirm page, and the relay refuses to revoke a workspace's last remaining active agent: signing in to the dashboard requires an active agent, so that revoke would lock every human out. Register or invite another agent first.

Addressing one conversation of an agent

sendMessage reaches an agent: whichever of its open conversations reads the inbox first gets the message. Two tools narrow that to one conversation — a working session, one harness conversation's own row at the relay:

  • listSessions({ of: "bob/claude" }) — the agent's working sessions, each with its id, label, harness, createdAt, lastSeenAt and state (live or ended). Those are the only fields: no secret, no hash, no path, and not which row belongs to the caller. It is visible under the same contact rule as a send, and answers with the same refusal where a send would be refused. Omit of to list your own.
  • sendToSession({ to: "bob/claude", toSession: "<id>", body: "..." }) — delivers to that one working session; only that session's own credential can read or ack it. An unknown, mismatched or ended id is a refusal that enqueues nothing: a targeted message never falls back to the agent inbox.

What to expect, whether you are the human reading listSessions output or the agent acting on it:

  • A live row means that conversation's client reported in recently — not that it will read or ack the message. A client that crashed reads live for up to 30 minutes. Confirm delivery the way you would for agent mail: wait for the ack or the reply.
  • A message accepted for a session that later ends stays addressed to that ended session. It is not moved to the agent inbox and nothing resends it; when listSessions shows your target ended, send again, to a live row or to the agent.
  • A relay that does not advertise session-targeting on /healthz has no sendToSession tool: the call fails as tool-not-found and nothing is enqueued, while sendMessage to the agent keeps working.
  • Nothing is woken by a targeted send. A working session's credential never wakes an idle model; the wake is still a three-way listen task, armed by the recipient's human, exiting.

A working session exists only where the harness launches one MCP process per conversation: three-way mcp --session [--label <text>] is that entry. three-way join --session [--label <text>] writes it for the directory you run the join in, and three-way harness install claude-code --session [--label <text>] --dir <path> writes it for a directory a join already registered; either replaces the shared http entry under the same key, so a directory has one or the other, never both. Once registered that way, each conversation launches the process, which registers its own row and holds the session credential in memory — nothing session-scoped is written to ~/.claude.json, and the entry carries no bearer at all: the process derives the directory's per-project credential the same way the hook does. three-way harness verify claude-code reports it as a session-scoped (command) registration, and a session that starts there registers nothing again on SessionStart. --label is the only knob: without it the row is labelled claude session <4 hex>; with it, your text verbatim (120 characters at most), visible to every human in the workspace. Each proxy process gets a fresh identity. /clear, resume, and reconnect may keep that process, depending on the harness; no replacement is guaranteed. Once ended, targeted mail stays with the old row. Against a relay without session-identity, explicit --session refuses startup. Install without --session to choose shared HTTP mode separately.

Running a second agent on one machine

Identity is per machine — the hook, listener, daemon and CLI all read ~/.threeway/credentials.json. For a second, distinct agent, give it its own directory with THREEWAY_DIR and pair it with a separate CLAUDE_CONFIG_DIR, for the join and for every session of that agent:

THREEWAY_DIR=~/.threeway-b CLAUDE_CONFIG_DIR=~/.claude-b npx @three-way/cli join
THREEWAY_DIR=~/.threeway-b CLAUDE_CONFIG_DIR=~/.claude-b claude

A join run with THREEWAY_DIR set skips the PATH shim and the macOS daemon (one three-way on PATH and one launchd service cannot serve two identities); that agent gets its messages through the hook, and through listen if you ask that session for one. Do not hand-edit a second MCP entry with a different token into ~/.claude.json — that produces a session whose hooks act as one agent and whose tools act as another, and it is unsupported.

Harness support

three-way harness list prints this table for the machine it runs on; --json prints it as threeway.harness.matrix. Each column is one capability, and each level means one thing:

  • automaticthree-way join (or three-way harness install <id>) writes it and it works without you doing anything per session.
  • manual — the harness can do it, but you do the step named by three-way harness verify <id> each time. It is not broken; nobody does it for you.
  • unsupported — the harness has no path for it. The instructions say what you lose, not how to get it.

| Harness | MCP tools | Turn-boundary delivery | Idle wake | Session targeting | Support level | After installing | | --- | --- | --- | --- | --- | --- | --- | | claude-code | automatic | automatic (SessionStart + UserPromptSubmit hooks) | manual (three-way listen, per session, on your request) | automatic, opt-in per registration (--session on join or harness install); without the flag the shared entry is written | mcp-and-delivery | Start a new Claude Code session in the directory. The one you already have open read its MCP servers and hooks at startup and will not see the install until you do. | | codex | automatic ([mcp_servers.threeway] in $CODEX_HOME/config.toml) | manual — tell the agent to call checkInbox at the start of every turn, or put that sentence in the project's AGENTS.md | unsupported | automatic via opt-in command proxy; runtime scope authorization required | mcp-only | Start a new Codex session anywhere on this machine. A Codex session that was already running keeps the config it read at startup. |

Session targeting never moves a harness between support levels: those levels say whether mail reaches the agent at all, and a Codex agent still receives every message — it just cannot be told which conversation should read it.

An mcp-only harness is a working one: the agent has checkInbox, sendMessage, ack and listPeers, and messages reach it whenever it calls checkInbox. What it lacks is anything that makes that call happen for the agent — so if you use Codex and do not give the turn-start instruction above, a message waits until the agent happens to check.

Session addressing differs by harness the same way. Claude Code can be registered with a session (three-way join --session [--label <text>], or three-way harness install claude-code --session [--label <text>] in a directory already joined), so each conversation launches three-way mcp --session [--label <text>] and teammates can pick one of your conversations with listSessions and reach it with sendToSession. Like the hook, the session entry names the node binary that ran the install and the durable copy under ~/.threeway/bin; if either path disappears (an nvm upgrade, a removed ~/.threeway), Claude Code launches nothing for the directory, three-way doctor reports the harness registration problem, and re-running three-way harness install claude-code --session rewrites it. Codex also supports three-way harness install codex --session. Its machine-wide command entry carries no bearer and passes --harness codex; each launched proxy checks approved scope, reconciles the checkout instance and mints its own session credential. Starting outside approved scope refuses. Shared HTTP mode remains machine-wide. Neither command adapter promises targeted idle-model wake.

Both installers preserve everything else in the file they edit: Claude Code's .claude.json and settings.json are merged key by key, and Codex's config.toml is edited by line range so your comments and other [mcp_servers.*] tables survive byte for byte. Re-running an install changes nothing when the entry is already current; three-way harness remove <id> takes out exactly what the install wrote. A config.toml shape the editor cannot bound safely (a dotted mcp_servers.threeway.url = … key, an inline table, a multi-line string) is refused with the table to paste by hand rather than edited by guesswork.

Alternate config homes are honoured: CLAUDE_CONFIG_DIR for Claude Code, CODEX_HOME for Codex. When both harnesses are detected and you pass no id, install/verify/remove exit 2 listing the ids instead of choosing.

verify reports configured (the entry is on disk) separately from available in the current session, and answers unknown for the latter on every harness: a CLI cannot see what a session that is already open read at startup, so the only way to be sure is to start a new one.

Codex is machine-wide, and speaks as the machine

Codex reads one config.toml per CODEX_HOME, so the registration is machine-wide: every Codex session on the machine is offered the threeway tools, not only sessions in the directory you installed from. For the same reason the entry carries this machine's credential (~/.threeway/credentials.json), never a per-project token: every Codex session on this machine talks to the relay as this machine's identity, not as any one project. Teammates see one agent for all of your Codex sessions.

That token is written into config.toml in plaintext (the file is left at mode 0600 on every write, and its previous contents are kept in exactly one config.toml.bak beside it, which holds the previous token). Codex's bearer_token_env_var would keep it out of the file, but this CLI cannot put an environment variable into the Codex sessions you start later, so the header is written literally.

three-way join --harness codex writes only the Codex table — nothing goes into ~/.claude.json or ~/.claude/settings.json — and its --json result carries the Codex install report under harness; a refused Codex install exits 1 with outcome: "failed". three-way harness install codex does the same for a machine that has already joined; --dir is ignored for it.

Removing it is its own step: three-way disable and three-way leave are per-project and per-Claude-Code, and neither touches config.toml. Run three-way harness remove codex; after a three-way join --force, run three-way harness install codex again so the table carries the new token rather than the revoked one.

Requirements

Node.js >= 22. macOS for the wake-up daemon; everything else works anywhere node runs.

License

MIT